Skip to content

Commit 880b3c4

Browse files
authored
Add MCP connection manager contract (#2197)
* Add MCP connection manager contract * Fix MCP registry lint alignment
1 parent 5aef383 commit 880b3c4

5 files changed

Lines changed: 710 additions & 0 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)