1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::{Arc, LazyLock};
14use tokio::sync::mpsc;
15use tracing::debug;
16
17pub struct MysqlProtocol {
19 _connection_id: ConnectionId,
20 _app_state: Arc<AppState>,
21 status_tx: mpsc::UnboundedSender<String>,
22}
23
24impl MysqlProtocol {
25 pub fn new(
26 connection_id: ConnectionId,
27 app_state: Arc<AppState>,
28 status_tx: mpsc::UnboundedSender<String>,
29 ) -> Self {
30 Self {
31 _connection_id: connection_id,
32 _app_state: app_state,
33 status_tx,
34 }
35 }
36}
37
38impl Protocol for MysqlProtocol {
40 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
41 vec![
42 crate::llm::actions::ParameterDefinition {
43 name: "send_first".to_string(),
44 type_hint: "boolean".to_string(),
45 description: "Unsupported by this server and refused if set to true. MySQL: the server already sends its initial handshake packet unconditionally, because the protocol requires it - there is nothing for this flag to turn on."
46 .to_string(),
47 required: false,
48 example: serde_json::json!(false),
49 default: None,
50 },
51 ]
52 }
53 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
54 vec![]
57 }
58 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
59 vec![
60 mysql_query_response_action(),
61 mysql_error_response_action(),
62 mysql_ok_response_action(),
63 close_this_connection_action(),
64 ]
65 }
66 fn protocol_name(&self) -> &'static str {
67 "MySQL"
68 }
69 fn get_event_types(&self) -> Vec<EventType> {
70 get_mysql_event_types()
71 }
72 fn stack_name(&self) -> &'static str {
73 "ETH>IP>TCP>MySQL"
74 }
75 fn keywords(&self) -> Vec<&'static str> {
76 vec!["mysql"]
77 }
78 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
79 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
80
81 ProtocolMetadataV2::builder()
82 .state(DevelopmentState::Beta)
88 .well_known_port(3306)
89 .implementation("opensrv-mysql v0.7 protocol library")
90 .max_inbound_bytes(crate::server::mysql::packet_limit::MAX_PACKET_BYTES)
95 .llm_control("Query responses (result sets, OK packets, ERR packets)")
96 .e2e_testing(
97 "Two independent clients, neither skipped nor #[ignore]d. (1) the mysql_async \
98 crate, text and binary (prepared) protocols \
99 (tests/server/mysql/test.rs, prepared_statement_test.rs). (2) the real \
100 `mysql` CLI, 9.3.0 (tests/server/mysql/real_client_test.rs): connect, a \
101 SELECT whose rows the model authors, and a statement answered with an ERR \
102 packet, asserted on what the client printed — including the \
103 caching_sha2_password fast-auth branch a client that was given a password \
104 takes. That test fails rather than skips when the binary is absent. Until \
105 September 2026 the CLI could not connect at all ('Authentication plugin \
106 mysql_native_password cannot be loaded'), so the rating rested on \
107 mysql_async alone being more permissive than the client a user would reach \
108 for.",
109 )
110 .notes(
111 "THIS SERVER AUTHENTICATES NOTHING. It offers caching_sha2_password so a \
112 current client can complete the connection phase, and then accepts whatever \
113 that client sent: no password is stored or compared, the model is not asked, \
114 the nonce is a fixed string, and mysql_native_password is still accepted \
115 too. 'Offers caching_sha2_password' is a statement about the packets, not \
116 about security. No TLS either. Prepared statements work and report their `?` \
117 count, but parameter values are not substituted - the model sees the `?`.",
118 )
119 .build()
120 }
121 fn description(&self) -> &'static str {
122 "MySQL database server"
123 }
124 fn example_prompt(&self) -> &'static str {
125 "Start a MySQL server on port 3306"
126 }
127 fn group_name(&self) -> &'static str {
128 "Database"
129 }
130
131 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
132 use crate::llm::actions::StartupExamples;
133 use serde_json::json;
134
135 let script = r#"import json, sys
138data = json.load(sys.stdin)
139event = data["event"]
140if data["event_type_id"] == "mysql_query":
141 q = event.get("query", "").strip().rstrip(";").upper()
142 if q.startswith("SELECT VERSION()"):
143 actions = [{"type": "mysql_query_response",
144 "columns": [{"name": "version", "type": "VARCHAR"}],
145 "rows": [["8.0.36"]]}]
146 elif q.startswith("SELECT"):
147 actions = [{"type": "mysql_query_response",
148 "columns": [{"name": "id", "type": "INT"},
149 {"name": "name", "type": "VARCHAR"}],
150 "rows": [[1, "alice"], [2, "bob"]]}]
151 else:
152 actions = [{"type": "mysql_ok_response", "affected_rows": 0}]
153else:
154 actions = []
155print(json.dumps({"actions": actions}))"#;
156
157 StartupExamples::new(
158 json!({
160 "type": "open_server",
161 "port": 3306,
162 "base_stack": "mysql",
163 "instruction": "Act as a MySQL server for a 'products' database. Answer SELECT queries by generating plausible rows that satisfy the WHERE clause and requested columns; treat INSERT/UPDATE/DELETE as succeeding and report affected_rows. Keep results consistent across queries in the same session."
164 }),
165 json!({
167 "type": "open_server",
168 "port": 3306,
169 "base_stack": "mysql",
170 "event_handlers": [{
171 "event_pattern": "mysql_query",
172 "handler": {
173 "type": "script",
174 "language": "python",
175 "code": script
176 }
177 }]
178 }),
179 json!({
181 "type": "open_server",
182 "port": 3306,
183 "base_stack": "mysql",
184 "event_handlers": [{
185 "event_pattern": "mysql_query",
186 "handler": {
187 "type": "static",
188 "actions": [{
189 "type": "mysql_query_response",
190 "columns": [{"name": "result", "type": "VARCHAR"}],
191 "rows": [["OK"]]
192 }]
193 }
194 }]
195 }),
196 )
197 }
198}
199
200impl Server for MysqlProtocol {
202 fn spawn(
203 &self,
204 ctx: crate::protocol::SpawnContext,
205 ) -> std::pin::Pin<
206 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
207 > {
208 Box::pin(async move {
209 use crate::server::mysql::MysqlServer;
210 let send_first = ctx
216 .startup_params
217 .as_ref()
218 .map(|p| p.get_optional_bool("send_first"))
219 .transpose()?
220 .flatten()
221 .unwrap_or(false);
222 if send_first {
223 return Err(anyhow::anyhow!(
224 "send_first is not supported by the MySQL server: the server already sends its initial handshake packet unconditionally, because the protocol requires it - there is nothing for this flag to turn on"
225 ));
226 }
227
228 MysqlServer::spawn_with_llm_actions(
229 ctx.legacy_listen_addr(),
230 ctx.llm_client,
231 ctx.state,
232 ctx.status_tx,
233 send_first,
234 ctx.server_id,
235 )
236 .await
237 })
238 }
239 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
240 let action_type = action
241 .get("type")
242 .and_then(|v| v.as_str())
243 .context("Missing 'type' field in action")?;
244
245 match action_type {
246 "mysql_query_response" => self.execute_mysql_query_response(action),
247 "mysql_error_response" => self.execute_mysql_error_response(action),
248 "mysql_ok_response" => self.execute_mysql_ok_response(action),
249 "close_this_connection" => Ok(ActionResult::CloseConnection),
250 _ => Err(anyhow::anyhow!("Unknown MySQL action: {}", action_type)),
251 }
252 }
253}
254
255impl MysqlProtocol {
256 fn execute_mysql_query_response(&self, action: serde_json::Value) -> Result<ActionResult> {
257 let columns = action
259 .get("columns")
260 .and_then(|v| v.as_array())
261 .context("Missing 'columns' array")?;
262
263 let rows = action
264 .get("rows")
265 .and_then(|v| v.as_array())
266 .context("Missing 'rows' array")?;
267
268 debug!(
269 "MySQL query response: {} columns, {} rows",
270 columns.len(),
271 rows.len()
272 );
273
274 let _ = self.status_tx.send(format!(
275 "[DEBUG] MySQL → Result set: {} columns, {} rows",
276 columns.len(),
277 rows.len()
278 ));
279
280 Ok(ActionResult::Custom {
282 name: "mysql_query_response".to_string(),
283 data: json!({
284 "columns": columns,
285 "rows": rows
286 }),
287 })
288 }
289
290 fn execute_mysql_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
291 let error_code = action
294 .get("error_code")
295 .and_then(|v| v.as_u64())
296 .and_then(|c| u16::try_from(c).ok())
297 .unwrap_or(1064);
298
299 let message = action
300 .get("message")
301 .and_then(|v| v.as_str())
302 .unwrap_or("Unknown error");
303
304 debug!("MySQL error response: {} - {}", error_code, message);
305
306 let _ = self
307 .status_tx
308 .send(format!("[DEBUG] MySQL ✗ Error {}: {}", error_code, message));
309
310 Ok(ActionResult::Custom {
311 name: "mysql_error".to_string(),
312 data: json!({
313 "error_code": error_code,
314 "message": message
315 }),
316 })
317 }
318
319 fn execute_mysql_ok_response(&self, action: serde_json::Value) -> Result<ActionResult> {
320 let affected_rows = action
321 .get("affected_rows")
322 .and_then(|v| v.as_u64())
323 .unwrap_or(0);
324
325 let last_insert_id = action
326 .get("last_insert_id")
327 .and_then(|v| v.as_u64())
328 .unwrap_or(0);
329
330 debug!(
331 "MySQL OK response: affected_rows={}, last_insert_id={}",
332 affected_rows, last_insert_id
333 );
334
335 let _ = self.status_tx.send(format!(
336 "[DEBUG] MySQL → OK: {} rows affected",
337 affected_rows
338 ));
339
340 Ok(ActionResult::Custom {
341 name: "mysql_ok".to_string(),
342 data: json!({
343 "affected_rows": affected_rows,
344 "last_insert_id": last_insert_id
345 }),
346 })
347 }
348}
349
350pub fn mysql_query_response_action() -> ActionDefinition {
352 ActionDefinition {
353 name: "mysql_query_response".to_string(),
354 description: "The answer to any statement that returns rows - SELECT (including \
355 SELECT 1, SELECT VERSION(), SELECT @@version_comment), SHOW, DESCRIBE. A \
356 single value is one column and one row, and each row is an ARRAY of \
357 values in column order, e.g. \"rows\": [[\"8.0.36\"]]. Never answer a \
358 SELECT with mysql_ok_response: an OK packet carries no rows, so the client \
359 prints nothing. One response per query."
360 .to_string(),
361 parameters: vec![
362 Parameter {
363 name: "columns".to_string(),
364 type_hint: "array".to_string(),
365 description: "Array of column definitions. Each column needs 'name' and 'type'. \
366 Recognised types: INT, INTEGER, BIGINT, SMALLINT, TINYINT, FLOAT, DOUBLE, \
367 DECIMAL, DATE, TIME, DATETIME, TIMESTAMP, BLOB, BINARY, TEXT, VARCHAR \
368 (anything else is treated as VARCHAR). The type sets the column metadata \
369 and how each value is encoded: send a number for the numeric types, \
370 'YYYY-MM-DD' for DATE, 'HH:MM:SS' for TIME, 'YYYY-MM-DD HH:MM:SS' for \
371 DATETIME/TIMESTAMP. A value that does not fit its column's type is sent \
372 as NULL".to_string(),
373 required: true,
374 },
375 Parameter {
376 name: "rows".to_string(),
377 type_hint: "array".to_string(),
378 description: "Array of rows. Each row is an array of values matching the column \
379 order. JSON null is a SQL NULL. Rows shorter than the column list \
380 are padded with NULLs; extra values are dropped".to_string(),
381 required: true,
382 },
383 ],
384 example: json!({
385 "type": "mysql_query_response",
386 "columns": [{"name": "id", "type": "INT"}, {"name": "name", "type": "VARCHAR"}],
387 "rows": [[1, "Alice"], [2, "Bob"]]
388 }),
389 log_template: Some(
390 LogTemplate::new()
391 .with_info("-> MySQL {columns_len} cols, {rows_len} rows")
392 .with_debug("MySQL mysql_query_response: {columns_len} columns, {rows_len} rows"),
393 ),
394 }
395}
396
397pub fn mysql_error_response_action() -> ActionDefinition {
399 ActionDefinition {
400 name: "mysql_error_response".to_string(),
401 description: "Send an error response to the client".to_string(),
402 parameters: vec![
403 Parameter {
404 name: "error_code".to_string(),
405 type_hint: "number".to_string(),
406 description:
407 "MySQL error number. Sent verbatim when it is one of the recognised codes: \
408 1044, 1045, 1046, 1049, 1050, 1051, 1052, 1054, 1062, 1064, 1065, 1136, \
409 1146, 1149, 1216, 1217, 1364, 1451, 1452, 1690. Any other value is reported \
410 to the client as 1105 (unknown error) with your message unchanged"
411 .to_string(),
412 required: true,
413 },
414 Parameter {
415 name: "message".to_string(),
416 type_hint: "string".to_string(),
417 description: "Error message to display to the client".to_string(),
418 required: true,
419 },
420 ],
421 example: json!({
422 "type": "mysql_error_response",
423 "error_code": 1146,
424 "message": "Table 'database.table_name' doesn't exist"
425 }),
426 log_template: Some(
427 LogTemplate::new()
428 .with_info("-> MySQL ERR {error_code}: {message}")
429 .with_debug("MySQL mysql_error_response: code={error_code}, message={message}"),
430 ),
431 }
432}
433
434pub fn mysql_ok_response_action() -> ActionDefinition {
436 ActionDefinition {
437 name: "mysql_ok_response".to_string(),
438 description: "OK packet for a statement that returns no rows: INSERT, UPDATE, \
439 DELETE, CREATE, SET, USE. Never for SELECT, SHOW or DESCRIBE - those are \
440 answered with mysql_query_response."
441 .to_string(),
442 parameters: vec![
443 Parameter {
444 name: "affected_rows".to_string(),
445 type_hint: "number".to_string(),
446 description: "Number of rows affected by the query".to_string(),
447 required: false,
448 },
449 Parameter {
450 name: "last_insert_id".to_string(),
451 type_hint: "number".to_string(),
452 description: "Last insert ID for INSERT queries with auto_increment".to_string(),
453 required: false,
454 },
455 ],
456 example: json!({
457 "type": "mysql_ok_response",
458 "affected_rows": 1,
459 "last_insert_id": 42
460 }),
461 log_template: Some(
462 LogTemplate::new()
463 .with_info("-> MySQL OK, {affected_rows} rows affected")
464 .with_debug("MySQL mysql_ok_response: affected_rows={affected_rows}, last_insert_id={last_insert_id}"),
465 ),
466 }
467}
468
469pub fn close_this_connection_action() -> ActionDefinition {
471 ActionDefinition {
472 name: "close_this_connection".to_string(),
473 description: "Close the current MySQL connection".to_string(),
474 parameters: vec![],
475 example: json!({"type": "close_this_connection"}),
476 log_template: Some(
477 LogTemplate::new()
478 .with_info("MySQL connection closed")
479 .with_debug("MySQL close_this_connection"),
480 ),
481 }
482}
483
484pub static MYSQL_QUERY_RESPONSE_ACTION: LazyLock<ActionDefinition> =
495 LazyLock::new(mysql_query_response_action);
496
497pub static MYSQL_ERROR_RESPONSE_ACTION: LazyLock<ActionDefinition> =
499 LazyLock::new(mysql_error_response_action);
500
501pub static MYSQL_OK_RESPONSE_ACTION: LazyLock<ActionDefinition> =
503 LazyLock::new(mysql_ok_response_action);
504
505pub static MYSQL_CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
507 LazyLock::new(close_this_connection_action);
508
509pub static MYSQL_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
515 EventType::new(
516 "mysql_query",
517 "MySQL query received from client",
518 json!({"type": "placeholder", "event_id": "mysql_query"}),
519 )
520 .with_parameters(vec![
521 Parameter {
522 name: "query".to_string(),
523 type_hint: "string".to_string(),
524 description: "The SQL query string sent by the client".to_string(),
525 required: true,
526 },
527 Parameter {
528 name: "answer_with".to_string(),
529 type_hint: "string".to_string(),
530 description: "Which response shape the statement needs, from its first keyword. \
531 Absent when the server cannot tell."
532 .to_string(),
533 required: false,
534 },
535 ])
536 .with_actions(vec![
537 MYSQL_QUERY_RESPONSE_ACTION.clone(),
538 MYSQL_ERROR_RESPONSE_ACTION.clone(),
539 MYSQL_OK_RESPONSE_ACTION.clone(),
540 MYSQL_CLOSE_CONNECTION_ACTION.clone(),
541 ])
542 .with_log_template(
543 LogTemplate::new()
544 .with_info("MySQL: {preview(query,80)}")
545 .with_debug("MySQL query: {query}")
546 .with_trace("MySQL: {json_pretty(.)}"),
547 )
548});
549
550pub fn answer_with_for_query(sql: &str) -> Option<&'static str> {
556 use crate::utils::sql::{statement_shape, StatementShape};
557 match statement_shape(sql) {
558 StatementShape::Rows => Some(
559 "mysql_query_response: this statement returns rows - columns, and rows as arrays of \
560 values (one column and one row for a single value) - never an OK packet",
561 ),
562 StatementShape::NoRows => Some(
563 "mysql_ok_response: this statement returns no rows - affected_rows and, for an \
564 INSERT, last_insert_id",
565 ),
566 StatementShape::Unknown => None,
567 }
568}
569
570pub fn get_mysql_event_types() -> Vec<EventType> {
572 vec![MYSQL_QUERY_EVENT.clone()]
573}