From 4884f169663101341c298fd8f593b69708d5b5bc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 22 May 2026 19:32:31 -0400 Subject: [PATCH 1/2] Add MCP connection manager contract --- inc/Engine/MCP/MCPConnectionManager.php | 229 ++++++++++++++++++++++++ inc/Engine/MCP/MCPServerRegistry.php | 200 +++++++++++++++++++++ inc/Engine/MCP/functions.php | 99 ++++++++++ inc/bootstrap.php | 1 + tests/mcp-connection-manager-smoke.php | 181 +++++++++++++++++++ 5 files changed, 710 insertions(+) create mode 100644 inc/Engine/MCP/MCPConnectionManager.php create mode 100644 inc/Engine/MCP/MCPServerRegistry.php create mode 100644 inc/Engine/MCP/functions.php create mode 100644 tests/mcp-connection-manager-smoke.php diff --git a/inc/Engine/MCP/MCPConnectionManager.php b/inc/Engine/MCP/MCPConnectionManager.php new file mode 100644 index 000000000..55716dbbc --- /dev/null +++ b/inc/Engine/MCP/MCPConnectionManager.php @@ -0,0 +1,229 @@ + + */ + private static array $connections = array(); + + /** + * Runtime state keyed by server id. + * + * @var array + */ + private static array $state = array(); + + /** + * Connect to a registered server through the installed connector hook. + * + * Data Machine core intentionally does not shell out or instantiate a concrete + * MCP transport here. Runtime adapters provide the client through the + * `datamachine_mcp_connector` filter. + * + * @param string $server_id Server id. + * @param array $context Caller context. + * @return mixed|\WP_Error + */ + public static function connect( string $server_id, array $context = array() ) { + $config = MCPServerRegistry::get( $server_id ); + if ( null === $config ) { + $error = self::error( 'datamachine_mcp_server_not_registered', sprintf( 'MCP server "%s" is not registered.', $server_id ) ); + self::set_state( $server_id, self::STATE_FAILED, $error ); + return $error; + } + + $server_id = (string) $config['server_id']; + if ( isset( self::$connections[ $server_id ] ) ) { + return self::$connections[ $server_id ]; + } + + self::set_state( $server_id, self::STATE_CONNECTING ); + + $connector = function_exists( 'apply_filters' ) ? apply_filters( 'datamachine_mcp_connector', null, $config, $context ) : null; + if ( null === $connector ) { + $error = self::error( 'datamachine_mcp_connector_missing', sprintf( 'No MCP connector is available for server "%s".', $server_id ) ); + self::set_state( $server_id, self::STATE_FAILED, $error ); + return $error; + } + + $connection = self::connect_with( $connector, $config, $context ); + if ( self::is_error( $connection ) ) { + self::set_state( $server_id, self::STATE_FAILED, $connection ); + return $connection; + } + + self::$connections[ $server_id ] = $connection; + self::set_state( $server_id, self::STATE_CONNECTED ); + + return $connection; + } + + /** + * Restart a server connection by cleaning up any existing handle first. + * + * @param string $server_id Server id. + * @param array $context Caller context. + * @return mixed|\WP_Error + */ + public static function restart( string $server_id, array $context = array() ) { + self::set_state( $server_id, self::STATE_RESTARTING ); + self::cleanup( $server_id ); + return self::connect( $server_id, $context ); + } + + /** + * Cleanup one server connection or all active connections. + * + * @param string|null $server_id Optional server id. + * @return void + */ + public static function cleanup( ?string $server_id = null ): void { + $server_ids = null === $server_id ? array_keys( self::$connections ) : array( $server_id ); + + foreach ( $server_ids as $id ) { + if ( isset( self::$connections[ $id ] ) ) { + self::close_connection( self::$connections[ $id ] ); + unset( self::$connections[ $id ] ); + } + + self::set_state( (string) $id, self::STATE_STOPPED ); + } + } + + /** + * Return redacted lifecycle state. + * + * @param string|null $server_id Optional server id. + * @return array|null + */ + public static function state( ?string $server_id = null ): ?array { + if ( null !== $server_id ) { + return self::$state[ $server_id ] ?? null; + } + + return self::$state; + } + + /** + * Reset manager runtime state for tests. + * + * @return void + */ + public static function reset(): void { + self::cleanup(); + self::$connections = array(); + self::$state = array(); + } + + /** + * Call a connector callback/object. + * + * @param mixed $connector Connector callback or object. + * @param array $config Server config. + * @param array $context Caller context. + * @return mixed|\WP_Error + */ + private static function connect_with( $connector, array $config, array $context ) { + if ( is_callable( $connector ) ) { + return $connector( $config, $context ); + } + + if ( is_object( $connector ) && method_exists( $connector, 'connect' ) ) { + return $connector->connect( $config, $context ); + } + + return self::error( 'datamachine_mcp_connector_invalid', 'The MCP connector must be callable or expose a connect() method.' ); + } + + /** + * Close a connection handle when it exposes a known cleanup method. + * + * @param mixed $connection Connection handle. + * @return void + */ + private static function close_connection( $connection ): void { + if ( ! is_object( $connection ) ) { + return; + } + + foreach ( array( 'cleanup', 'close', 'disconnect', 'stop' ) as $method ) { + if ( method_exists( $connection, $method ) ) { + $connection->{$method}(); + return; + } + } + } + + /** + * Store lifecycle state without leaking config or connection handles. + * + * @param string $server_id Server id. + * @param string $status Status. + * @param \WP_Error|null $error Optional error. + * @return void + */ + private static function set_state( string $server_id, string $status, ?\WP_Error $error = null ): void { + $entry = array( + 'server_id' => $server_id, + 'status' => $status, + 'updated_at' => gmdate( 'c' ), + ); + + if ( null !== $error ) { + $entry['error'] = array( + 'code' => $error->get_error_code(), + 'message' => $error->get_error_message(), + ); + } + + $config = MCPServerRegistry::get( $server_id, true ); + if ( null !== $config ) { + $entry['config'] = $config; + } + + self::$state[ $server_id ] = $entry; + } + + /** + * Whether a value is a WP_Error. + * + * @param mixed $value Value. + * @return bool + */ + private static function is_error( $value ): bool { + return function_exists( 'is_wp_error' ) ? is_wp_error( $value ) : $value instanceof \WP_Error; + } + + /** + * Build a WP_Error. + * + * @param string $code Error code. + * @param string $message Error message. + * @return \WP_Error + */ + private static function error( string $code, string $message ): \WP_Error { + return new \WP_Error( $code, $message ); + } +} diff --git a/inc/Engine/MCP/MCPServerRegistry.php b/inc/Engine/MCP/MCPServerRegistry.php new file mode 100644 index 000000000..528e36cd9 --- /dev/null +++ b/inc/Engine/MCP/MCPServerRegistry.php @@ -0,0 +1,200 @@ +|null + */ + private static ?array $servers = null; + + /** + * Register a server for the current request. + * + * Persistent registration should happen through the `datamachine_mcp_servers` + * filter; this method exists for bootstrap helpers and tests. + * + * @param string $server_id Server id. + * @param array $config Server config. + * @return bool + */ + public static function register( string $server_id, array $config ): bool { + $server_id = self::normalize_id( $server_id ); + if ( '' === $server_id ) { + return false; + } + + self::load(); + self::$servers[ $server_id ] = self::normalize_config( $server_id, $config ); + return true; + } + + /** + * Return all registered servers. + * + * @param bool $redacted Whether to redact sensitive config fields. + * @return array + */ + public static function all( bool $redacted = false ): array { + self::load(); + if ( ! $redacted ) { + return self::$servers; + } + + $servers = array(); + foreach ( self::$servers as $server_id => $config ) { + $servers[ $server_id ] = self::redact_config( $config ); + } + + return $servers; + } + + /** + * Get one registered server config. + * + * @param string $server_id Server id. + * @param bool $redacted Whether to redact sensitive config fields. + * @return array|null + */ + public static function get( string $server_id, bool $redacted = false ): ?array { + self::load(); + $server_id = self::normalize_id( $server_id ); + if ( '' === $server_id || ! isset( self::$servers[ $server_id ] ) ) { + return null; + } + + $config = self::$servers[ $server_id ]; + return $redacted ? self::redact_config( $config ) : $config; + } + + /** + * Whether a server is registered. + * + * @param string $server_id Server id. + * @return bool + */ + public static function is_registered( string $server_id ): bool { + return null !== self::get( $server_id ); + } + + /** + * Clear cached registrations for tests. + * + * @return void + */ + public static function reset(): void { + self::$servers = null; + } + + /** + * Redact sensitive values recursively. + * + * @param mixed $value Value to redact. + * @param string $key Current key. + * @return mixed + */ + public static function redact_value( $value, string $key = '' ) { + if ( '' !== $key && preg_match( '/(authorization|credential|secret|token|password|api[_-]?key|bearer)/i', $key ) ) { + return '[redacted]'; + } + + if ( ! is_array( $value ) ) { + return $value; + } + + $redacted = array(); + foreach ( $value as $child_key => $child_value ) { + $redacted[ $child_key ] = self::redact_value( $child_value, (string) $child_key ); + } + + return $redacted; + } + + /** + * Load server definitions from the registry filter once per request. + * + * @return void + */ + private static function load(): void { + if ( null !== self::$servers ) { + return; + } + + $registered = function_exists( 'apply_filters' ) ? apply_filters( 'datamachine_mcp_servers', array() ) : array(); + self::$servers = array(); + + if ( ! is_array( $registered ) ) { + return; + } + + foreach ( $registered as $server_id => $config ) { + if ( ! is_array( $config ) ) { + continue; + } + + $server_id = self::normalize_id( (string) $server_id ); + if ( '' === $server_id ) { + continue; + } + + self::$servers[ $server_id ] = self::normalize_config( $server_id, $config ); + } + } + + /** + * Normalize a server config. + * + * @param string $server_id Server id. + * @param array $config Raw config. + * @return array + */ + private static function normalize_config( string $server_id, array $config ): array { + $config['server_id'] = $server_id; + $config['transport'] = isset( $config['transport'] ) ? strtolower( (string) $config['transport'] ) : 'http'; + $config['headers'] = is_array( $config['headers'] ?? null ) ? $config['headers'] : array(); + $config['env'] = is_array( $config['env'] ?? null ) ? $config['env'] : array(); + $config['args'] = is_array( $config['args'] ?? null ) ? array_values( $config['args'] ) : array(); + + ksort( $config, SORT_STRING ); + return $config; + } + + /** + * Redact a normalized config. + * + * @param array $config Config. + * @return array + */ + private static function redact_config( array $config ): array { + $redacted = self::redact_value( $config ); + return is_array( $redacted ) ? $redacted : array(); + } + + /** + * Normalize a server id into the shared registry vocabulary. + * + * @param string $server_id Raw server id. + * @return string + */ + private static function normalize_id( string $server_id ): string { + $server_id = strtolower( trim( $server_id ) ); + if ( '' === $server_id || ! preg_match( '/^[a-z0-9][a-z0-9._-]*$/', $server_id ) ) { + return ''; + } + + return $server_id; + } +} diff --git a/inc/Engine/MCP/functions.php b/inc/Engine/MCP/functions.php new file mode 100644 index 000000000..366363041 --- /dev/null +++ b/inc/Engine/MCP/functions.php @@ -0,0 +1,99 @@ + + */ + function datamachine_mcp_servers( bool $redacted = true ): array { + return MCPServerRegistry::all( $redacted ); + } +} + +if ( ! function_exists( 'datamachine_mcp_server' ) ) { + /** + * Return one MCP server config. + * + * @param string $server_id Server id. + * @param bool $redacted Whether to redact sensitive config fields. + * @return array|null + */ + function datamachine_mcp_server( string $server_id, bool $redacted = true ): ?array { + return MCPServerRegistry::get( $server_id, $redacted ); + } +} + +if ( ! function_exists( 'datamachine_mcp_connect' ) ) { + /** + * Connect to a registered MCP server. + * + * @param string $server_id Server id. + * @param array $context Caller context. + * @return mixed|WP_Error + */ + function datamachine_mcp_connect( string $server_id, array $context = array() ) { + return MCPConnectionManager::connect( $server_id, $context ); + } +} + +if ( ! function_exists( 'datamachine_mcp_restart' ) ) { + /** + * Restart a registered MCP server connection. + * + * @param string $server_id Server id. + * @param array $context Caller context. + * @return mixed|WP_Error + */ + function datamachine_mcp_restart( string $server_id, array $context = array() ) { + return MCPConnectionManager::restart( $server_id, $context ); + } +} + +if ( ! function_exists( 'datamachine_mcp_cleanup' ) ) { + /** + * Cleanup one MCP connection or all active MCP connections. + * + * @param string|null $server_id Optional server id. + * @return void + */ + function datamachine_mcp_cleanup( ?string $server_id = null ): void { + MCPConnectionManager::cleanup( $server_id ); + } +} + +if ( ! function_exists( 'datamachine_mcp_state' ) ) { + /** + * Return redacted MCP connection state. + * + * @param string|null $server_id Optional server id. + * @return array|null + */ + function datamachine_mcp_state( ?string $server_id = null ): ?array { + return MCPConnectionManager::state( $server_id ); + } +} diff --git a/inc/bootstrap.php b/inc/bootstrap.php index a5352c0c0..9652aff94 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -24,6 +24,7 @@ require_once __DIR__ . '/Engine/Filters/Handlers.php'; require_once __DIR__ . '/Engine/Filters/Admin.php'; require_once __DIR__ . '/Engine/Logger.php'; +require_once __DIR__ . '/Engine/MCP/functions.php'; require_once __DIR__ . '/Engine/Filters/OAuth.php'; require_once __DIR__ . '/Engine/Actions/DataMachineActions.php'; require_once __DIR__ . '/Engine/Filters/EngineData.php'; diff --git a/tests/mcp-connection-manager-smoke.php b/tests/mcp-connection-manager-smoke.php new file mode 100644 index 000000000..8b6ac409e --- /dev/null +++ b/tests/mcp-connection-manager-smoke.php @@ -0,0 +1,181 @@ +code = $code; + $this->message = $message; + } + + public function get_error_code(): string { + return $this->code; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; + } +} + +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $value ): string { + return (string) json_encode( $value ); + } +} + +$GLOBALS['datamachine_mcp_smoke_filters'] = array(); + +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { + $GLOBALS['datamachine_mcp_smoke_filters'][ $hook ][ $priority ][] = array( $callback, $accepted_args ); + } +} + +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + if ( empty( $GLOBALS['datamachine_mcp_smoke_filters'][ $hook ] ) ) { + return $value; + } + + ksort( $GLOBALS['datamachine_mcp_smoke_filters'][ $hook ], SORT_NUMERIC ); + foreach ( $GLOBALS['datamachine_mcp_smoke_filters'][ $hook ] as $callbacks ) { + foreach ( $callbacks as [ $callback, $accepted_args ] ) { + $value = $callback( ...array_slice( array_merge( array( $value ), $args ), 0, $accepted_args ) ); + } + } + + return $value; + } +} + +require_once __DIR__ . '/../inc/Engine/MCP/MCPServerRegistry.php'; +require_once __DIR__ . '/../inc/Engine/MCP/MCPConnectionManager.php'; +require_once __DIR__ . '/../inc/Engine/MCP/functions.php'; + +use DataMachine\Engine\MCP\MCPConnectionManager; +use DataMachine\Engine\MCP\MCPServerRegistry; + +final class FixtureMCPConnection { + public static int $closed = 0; + + public function close(): void { + ++self::$closed; + } +} + +$passes = 0; +$fails = 0; + +$assert = static function ( string $label, bool $condition ) use ( &$passes, &$fails ): void { + if ( $condition ) { + echo "PASS: {$label}\n"; + ++$passes; + return; + } + + echo "FAIL: {$label}\n"; + ++$fails; +}; + +add_filter( + 'datamachine_mcp_servers', + static function ( array $servers ): array { + $servers['context-a8c'] = array( + 'transport' => 'http', + 'url' => 'https://mcp.example.test/v1', + 'headers' => array( + 'Authorization' => 'Bearer should-not-leak', + 'Accept' => 'application/json', + ), + 'env' => array( + 'PUBLIC_FLAG' => '1', + 'API_TOKEN' => 'should-not-leak', + ), + 'auth_ref' => 'wpcom:default', + ); + + return $servers; + } +); + +echo "\n[1] Registry loads and normalizes server configs\n"; +MCPServerRegistry::reset(); +MCPConnectionManager::reset(); + +$raw = datamachine_mcp_server( 'context-a8c', false ); +$assert( 'registered server is available', is_array( $raw ) ); +$assert( 'server id is normalized into config', 'context-a8c' === ( $raw['server_id'] ?? null ) ); +$assert( 'default arrays are normalized', isset( $raw['args'] ) && is_array( $raw['args'] ) ); +$assert( 'raw config preserves local authorization value for runtime adapter', 'Bearer should-not-leak' === ( $raw['headers']['Authorization'] ?? null ) ); + +$assert( 'request-local helper registration succeeds', datamachine_mcp_register_server( 'wporg', array( 'url' => 'https://wporg.example.test/mcp' ) ) ); +$assert( 'helper-registered server is readable', 'wporg' === ( datamachine_mcp_server( 'wporg', false )['server_id'] ?? null ) ); + +echo "\n[2] Redacted config never leaks secrets\n"; +$redacted = datamachine_mcp_server( 'context-a8c', true ); +$encoded = wp_json_encode( $redacted ); +$assert( 'authorization header is redacted', '[redacted]' === ( $redacted['headers']['Authorization'] ?? null ) ); +$assert( 'token-like env key is redacted', '[redacted]' === ( $redacted['env']['API_TOKEN'] ?? null ) ); +$assert( 'non-secret env key remains visible', '1' === ( $redacted['env']['PUBLIC_FLAG'] ?? null ) ); +$assert( 'redacted output does not include bearer value', ! str_contains( $encoded, 'should-not-leak' ) ); + +echo "\n[3] Missing connector reports failed state without fake runtime\n"; +$missing = datamachine_mcp_connect( 'context-a8c' ); +$state = datamachine_mcp_state( 'context-a8c' ); +$assert( 'missing connector returns WP_Error', is_wp_error( $missing ) && 'datamachine_mcp_connector_missing' === $missing->get_error_code() ); +$assert( 'failed state is tracked', 'failed' === ( $state['status'] ?? null ) ); +$assert( 'state config is redacted', '[redacted]' === ( $state['config']['headers']['Authorization'] ?? null ) ); + +echo "\n[4] Connector hook provides reusable connection and cleanup lifecycle\n"; +add_filter( + 'datamachine_mcp_connector', + static function ( $connector, array $config, array $context ) { + unset( $connector, $context ); + if ( 'context-a8c' !== ( $config['server_id'] ?? null ) ) { + return null; + } + + return static fn() => new FixtureMCPConnection(); + }, + 10, + 3 +); + +MCPConnectionManager::reset(); +$first = datamachine_mcp_connect( 'context-a8c' ); +$second = datamachine_mcp_connect( 'context-a8c' ); +$assert( 'connector returns fixture connection', $first instanceof FixtureMCPConnection ); +$assert( 'connection is reused within the request', $first === $second ); +$assert( 'connected state is tracked', 'connected' === ( datamachine_mcp_state( 'context-a8c' )['status'] ?? null ) ); + +$restarted = datamachine_mcp_restart( 'context-a8c' ); +$assert( 'restart returns a fresh connection', $restarted instanceof FixtureMCPConnection && $restarted !== $first ); +$assert( 'restart closes previous connection', 1 === FixtureMCPConnection::$closed ); + +datamachine_mcp_cleanup( 'context-a8c' ); +$assert( 'cleanup closes active connection', 2 === FixtureMCPConnection::$closed ); +$assert( 'cleanup tracks stopped state', 'stopped' === ( datamachine_mcp_state( 'context-a8c' )['status'] ?? null ) ); + +echo "\n=== Results ===\n"; +echo "Passed: {$passes}\n"; +echo "Failed: {$fails}\n"; + +if ( $fails > 0 ) { + exit( 1 ); +} From ae140d734c8555bd7874009516066d1884e6847c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 23 May 2026 09:58:42 -0400 Subject: [PATCH 2/2] Fix MCP registry lint alignment --- inc/Engine/MCP/MCPServerRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/Engine/MCP/MCPServerRegistry.php b/inc/Engine/MCP/MCPServerRegistry.php index 528e36cd9..bbe358d51 100644 --- a/inc/Engine/MCP/MCPServerRegistry.php +++ b/inc/Engine/MCP/MCPServerRegistry.php @@ -133,7 +133,7 @@ private static function load(): void { return; } - $registered = function_exists( 'apply_filters' ) ? apply_filters( 'datamachine_mcp_servers', array() ) : array(); + $registered = function_exists( 'apply_filters' ) ? apply_filters( 'datamachine_mcp_servers', array() ) : array(); self::$servers = array(); if ( ! is_array( $registered ) ) {