|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * MCP connection manager. |
| 4 | + * |
| 5 | + * @package DataMachine\Engine\MCP |
| 6 | + */ |
| 7 | + |
| 8 | +namespace DataMachine\Engine\MCP; |
| 9 | + |
| 10 | +defined( 'ABSPATH' ) || exit; |
| 11 | + |
| 12 | +/** |
| 13 | + * Generic MCP connection lifecycle coordinator. |
| 14 | + */ |
| 15 | +final class MCPConnectionManager { |
| 16 | + |
| 17 | + public const STATE_REGISTERED = 'registered'; |
| 18 | + public const STATE_CONNECTING = 'connecting'; |
| 19 | + public const STATE_CONNECTED = 'connected'; |
| 20 | + public const STATE_FAILED = 'failed'; |
| 21 | + public const STATE_STOPPED = 'stopped'; |
| 22 | + public const STATE_RESTARTING = 'restarting'; |
| 23 | + |
| 24 | + /** |
| 25 | + * Runtime connection handles keyed by server id. |
| 26 | + * |
| 27 | + * @var array<string,mixed> |
| 28 | + */ |
| 29 | + private static array $connections = array(); |
| 30 | + |
| 31 | + /** |
| 32 | + * Runtime state keyed by server id. |
| 33 | + * |
| 34 | + * @var array<string,array> |
| 35 | + */ |
| 36 | + private static array $state = array(); |
| 37 | + |
| 38 | + /** |
| 39 | + * Connect to a registered server through the installed connector hook. |
| 40 | + * |
| 41 | + * Data Machine core intentionally does not shell out or instantiate a concrete |
| 42 | + * MCP transport here. Runtime adapters provide the client through the |
| 43 | + * `datamachine_mcp_connector` filter. |
| 44 | + * |
| 45 | + * @param string $server_id Server id. |
| 46 | + * @param array $context Caller context. |
| 47 | + * @return mixed|\WP_Error |
| 48 | + */ |
| 49 | + public static function connect( string $server_id, array $context = array() ) { |
| 50 | + $config = MCPServerRegistry::get( $server_id ); |
| 51 | + if ( null === $config ) { |
| 52 | + $error = self::error( 'datamachine_mcp_server_not_registered', sprintf( 'MCP server "%s" is not registered.', $server_id ) ); |
| 53 | + self::set_state( $server_id, self::STATE_FAILED, $error ); |
| 54 | + return $error; |
| 55 | + } |
| 56 | + |
| 57 | + $server_id = (string) $config['server_id']; |
| 58 | + if ( isset( self::$connections[ $server_id ] ) ) { |
| 59 | + return self::$connections[ $server_id ]; |
| 60 | + } |
| 61 | + |
| 62 | + self::set_state( $server_id, self::STATE_CONNECTING ); |
| 63 | + |
| 64 | + $connector = function_exists( 'apply_filters' ) ? apply_filters( 'datamachine_mcp_connector', null, $config, $context ) : null; |
| 65 | + if ( null === $connector ) { |
| 66 | + $error = self::error( 'datamachine_mcp_connector_missing', sprintf( 'No MCP connector is available for server "%s".', $server_id ) ); |
| 67 | + self::set_state( $server_id, self::STATE_FAILED, $error ); |
| 68 | + return $error; |
| 69 | + } |
| 70 | + |
| 71 | + $connection = self::connect_with( $connector, $config, $context ); |
| 72 | + if ( self::is_error( $connection ) ) { |
| 73 | + self::set_state( $server_id, self::STATE_FAILED, $connection ); |
| 74 | + return $connection; |
| 75 | + } |
| 76 | + |
| 77 | + self::$connections[ $server_id ] = $connection; |
| 78 | + self::set_state( $server_id, self::STATE_CONNECTED ); |
| 79 | + |
| 80 | + return $connection; |
| 81 | + } |
| 82 | + |
| 83 | + /** |
| 84 | + * Restart a server connection by cleaning up any existing handle first. |
| 85 | + * |
| 86 | + * @param string $server_id Server id. |
| 87 | + * @param array $context Caller context. |
| 88 | + * @return mixed|\WP_Error |
| 89 | + */ |
| 90 | + public static function restart( string $server_id, array $context = array() ) { |
| 91 | + self::set_state( $server_id, self::STATE_RESTARTING ); |
| 92 | + self::cleanup( $server_id ); |
| 93 | + return self::connect( $server_id, $context ); |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * Cleanup one server connection or all active connections. |
| 98 | + * |
| 99 | + * @param string|null $server_id Optional server id. |
| 100 | + * @return void |
| 101 | + */ |
| 102 | + public static function cleanup( ?string $server_id = null ): void { |
| 103 | + $server_ids = null === $server_id ? array_keys( self::$connections ) : array( $server_id ); |
| 104 | + |
| 105 | + foreach ( $server_ids as $id ) { |
| 106 | + if ( isset( self::$connections[ $id ] ) ) { |
| 107 | + self::close_connection( self::$connections[ $id ] ); |
| 108 | + unset( self::$connections[ $id ] ); |
| 109 | + } |
| 110 | + |
| 111 | + self::set_state( (string) $id, self::STATE_STOPPED ); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + /** |
| 116 | + * Return redacted lifecycle state. |
| 117 | + * |
| 118 | + * @param string|null $server_id Optional server id. |
| 119 | + * @return array|null |
| 120 | + */ |
| 121 | + public static function state( ?string $server_id = null ): ?array { |
| 122 | + if ( null !== $server_id ) { |
| 123 | + return self::$state[ $server_id ] ?? null; |
| 124 | + } |
| 125 | + |
| 126 | + return self::$state; |
| 127 | + } |
| 128 | + |
| 129 | + /** |
| 130 | + * Reset manager runtime state for tests. |
| 131 | + * |
| 132 | + * @return void |
| 133 | + */ |
| 134 | + public static function reset(): void { |
| 135 | + self::cleanup(); |
| 136 | + self::$connections = array(); |
| 137 | + self::$state = array(); |
| 138 | + } |
| 139 | + |
| 140 | + /** |
| 141 | + * Call a connector callback/object. |
| 142 | + * |
| 143 | + * @param mixed $connector Connector callback or object. |
| 144 | + * @param array $config Server config. |
| 145 | + * @param array $context Caller context. |
| 146 | + * @return mixed|\WP_Error |
| 147 | + */ |
| 148 | + private static function connect_with( $connector, array $config, array $context ) { |
| 149 | + if ( is_callable( $connector ) ) { |
| 150 | + return $connector( $config, $context ); |
| 151 | + } |
| 152 | + |
| 153 | + if ( is_object( $connector ) && method_exists( $connector, 'connect' ) ) { |
| 154 | + return $connector->connect( $config, $context ); |
| 155 | + } |
| 156 | + |
| 157 | + return self::error( 'datamachine_mcp_connector_invalid', 'The MCP connector must be callable or expose a connect() method.' ); |
| 158 | + } |
| 159 | + |
| 160 | + /** |
| 161 | + * Close a connection handle when it exposes a known cleanup method. |
| 162 | + * |
| 163 | + * @param mixed $connection Connection handle. |
| 164 | + * @return void |
| 165 | + */ |
| 166 | + private static function close_connection( $connection ): void { |
| 167 | + if ( ! is_object( $connection ) ) { |
| 168 | + return; |
| 169 | + } |
| 170 | + |
| 171 | + foreach ( array( 'cleanup', 'close', 'disconnect', 'stop' ) as $method ) { |
| 172 | + if ( method_exists( $connection, $method ) ) { |
| 173 | + $connection->{$method}(); |
| 174 | + return; |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + /** |
| 180 | + * Store lifecycle state without leaking config or connection handles. |
| 181 | + * |
| 182 | + * @param string $server_id Server id. |
| 183 | + * @param string $status Status. |
| 184 | + * @param \WP_Error|null $error Optional error. |
| 185 | + * @return void |
| 186 | + */ |
| 187 | + private static function set_state( string $server_id, string $status, ?\WP_Error $error = null ): void { |
| 188 | + $entry = array( |
| 189 | + 'server_id' => $server_id, |
| 190 | + 'status' => $status, |
| 191 | + 'updated_at' => gmdate( 'c' ), |
| 192 | + ); |
| 193 | + |
| 194 | + if ( null !== $error ) { |
| 195 | + $entry['error'] = array( |
| 196 | + 'code' => $error->get_error_code(), |
| 197 | + 'message' => $error->get_error_message(), |
| 198 | + ); |
| 199 | + } |
| 200 | + |
| 201 | + $config = MCPServerRegistry::get( $server_id, true ); |
| 202 | + if ( null !== $config ) { |
| 203 | + $entry['config'] = $config; |
| 204 | + } |
| 205 | + |
| 206 | + self::$state[ $server_id ] = $entry; |
| 207 | + } |
| 208 | + |
| 209 | + /** |
| 210 | + * Whether a value is a WP_Error. |
| 211 | + * |
| 212 | + * @param mixed $value Value. |
| 213 | + * @return bool |
| 214 | + */ |
| 215 | + private static function is_error( $value ): bool { |
| 216 | + return function_exists( 'is_wp_error' ) ? is_wp_error( $value ) : $value instanceof \WP_Error; |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Build a WP_Error. |
| 221 | + * |
| 222 | + * @param string $code Error code. |
| 223 | + * @param string $message Error message. |
| 224 | + * @return \WP_Error |
| 225 | + */ |
| 226 | + private static function error( string $code, string $message ): \WP_Error { |
| 227 | + return new \WP_Error( $code, $message ); |
| 228 | + } |
| 229 | +} |
0 commit comments