db.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. <?php
  2. /**
  3. * UNA database MCP server (stdio, protocol 2026-07-28).
  4. *
  5. * Credentials come from inc/header.inc.php. The rest of UNA is not loaded.
  6. * Messages are one JSON-RPC object per line on stdin. Responses are one
  7. * JSON-RPC object per line on stdout. Logs go to stderr.
  8. *
  9. * docker compose exec -T -w /opt/una php php tools/db.php <<'EOF'
  10. * {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query","arguments":{"sql":"SELECT 1"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
  11. * EOF
  12. */
  13. if (PHP_SAPI !== 'cli')
  14. exit(1);
  15. const MCP_PROTOCOL_VERSION = '2026-07-28';
  16. const MCP_SERVER_NAME = 'una-db';
  17. const MCP_SERVER_VERSION = '1.0.0';
  18. const MAX_RESULT_ROWS = 1000;
  19. const JSONRPC_PARSE_ERROR = -32700;
  20. const JSONRPC_INVALID_REQUEST = -32600;
  21. const JSONRPC_METHOD_NOT_FOUND = -32601;
  22. const JSONRPC_INVALID_PARAMS = -32602;
  23. const MCP_UNSUPPORTED_PROTOCOL_VERSION = -32022;
  24. if (isset($argv[1])) {
  25. fwrite(STDERR, usage());
  26. exit($argv[1] === '--help' || $argv[1] === '-h' ? 0 : 2);
  27. }
  28. if (stream_isatty(STDIN))
  29. fwrite(STDERR, "una-db MCP server (" . MCP_PROTOCOL_VERSION . "), reading JSON-RPC from stdin\n");
  30. while (($sLine = fgets(STDIN)) !== false) {
  31. $sLine = rtrim($sLine, "\r\n");
  32. if ($sLine === '')
  33. continue;
  34. handleLine($sLine);
  35. }
  36. exit(0);
  37. function handleLine(string $sLine): void
  38. {
  39. try {
  40. $mixed = json_decode($sLine, false, 512, JSON_THROW_ON_ERROR);
  41. }
  42. catch (JsonException $oException) {
  43. writeError(null, JSONRPC_PARSE_ERROR, 'Parse error');
  44. return;
  45. }
  46. if (!is_object($mixed)) {
  47. writeError(null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
  48. return;
  49. }
  50. $bHasId = property_exists($mixed, 'id');
  51. $mId = $bHasId ? $mixed->id : null;
  52. $bValidId = is_string($mId) || is_int($mId);
  53. if (!$bHasId)
  54. return;
  55. $sMethod = $mixed->method ?? null;
  56. if (!$bValidId || ($mixed->jsonrpc ?? null) !== '2.0' || !is_string($sMethod) || $sMethod === '') {
  57. writeError($bValidId ? $mId : null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
  58. return;
  59. }
  60. if (isset($mixed->params) && !is_object($mixed->params)) {
  61. writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params');
  62. return;
  63. }
  64. $oParams = $mixed->params ?? new stdClass();
  65. $aProtocolError = protocolError($oParams);
  66. if ($aProtocolError !== null) {
  67. writeError($mId, $aProtocolError[0], $aProtocolError[1], $aProtocolError[2]);
  68. return;
  69. }
  70. switch ($sMethod) {
  71. case 'server/discover':
  72. writeResult($mId, [
  73. 'supportedVersions' => [MCP_PROTOCOL_VERSION],
  74. 'capabilities' => [
  75. 'tools' => ['listChanged' => false],
  76. ],
  77. 'instructions' => 'Run SQL on the installed UNA database with the query tool. Statements execute on the live database for this instance.',
  78. 'ttlMs' => 86400000,
  79. 'cacheScope' => 'public',
  80. ]);
  81. return;
  82. case 'tools/list':
  83. if (isset($oParams->cursor) && $oParams->cursor !== '') {
  84. if (!is_string($oParams->cursor)) {
  85. writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params: cursor must be a string');
  86. return;
  87. }
  88. writeResult($mId, [
  89. 'tools' => [],
  90. 'ttlMs' => 86400000,
  91. 'cacheScope' => 'public',
  92. ]);
  93. return;
  94. }
  95. writeResult($mId, [
  96. 'tools' => [queryTool()],
  97. 'ttlMs' => 86400000,
  98. 'cacheScope' => 'public',
  99. ]);
  100. return;
  101. case 'tools/call':
  102. $aCall = callTool($oParams);
  103. if (isset($aCall['error'])) {
  104. writeError($mId, $aCall['error'][0], $aCall['error'][1]);
  105. return;
  106. }
  107. writeResult($mId, $aCall['result']);
  108. return;
  109. default:
  110. writeError($mId, JSONRPC_METHOD_NOT_FOUND, 'Method not found');
  111. }
  112. }
  113. /**
  114. * @return array{0: int, 1: string, 2: ?array}|null
  115. */
  116. function protocolError(object $oParams): ?array
  117. {
  118. if (!isset($oParams->_meta) || !is_object($oParams->_meta))
  119. return [JSONRPC_INVALID_PARAMS, 'Invalid params: _meta is required', null];
  120. $oMeta = $oParams->_meta;
  121. $sVersion = $oMeta->{'io.modelcontextprotocol/protocolVersion'} ?? null;
  122. if (!is_string($sVersion) || $sVersion === '')
  123. return [JSONRPC_INVALID_PARAMS, 'Invalid params: protocol version is required', null];
  124. if ($sVersion !== MCP_PROTOCOL_VERSION) {
  125. return [MCP_UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
  126. 'supported' => [MCP_PROTOCOL_VERSION],
  127. 'requested' => $sVersion,
  128. ]];
  129. }
  130. $mCapabilities = $oMeta->{'io.modelcontextprotocol/clientCapabilities'} ?? null;
  131. if (!is_object($mCapabilities))
  132. return [JSONRPC_INVALID_PARAMS, 'Invalid params: client capabilities are required', null];
  133. return null;
  134. }
  135. function queryTool(): array
  136. {
  137. return [
  138. 'name' => 'query',
  139. 'title' => 'Query UNA database',
  140. 'description' => 'Run SQL against the installed UNA database. Several statements separated by semicolons run in one transaction and commit together. If a later statement fails, earlier statements in that transaction are rolled back. A statement that commits on its own, such as a schema change, is kept, and the error includes the results of statements that already committed. Each result set returns at most ' . MAX_RESULT_ROWS . ' rows and sets truncated when more rows matched.',
  141. 'inputSchema' => [
  142. 'type' => 'object',
  143. 'properties' => [
  144. 'sql' => [
  145. 'type' => 'string',
  146. 'description' => 'SQL to execute.',
  147. ],
  148. ],
  149. 'required' => ['sql'],
  150. 'additionalProperties' => false,
  151. ],
  152. 'outputSchema' => [
  153. 'type' => 'object',
  154. 'properties' => [
  155. 'results' => [
  156. 'type' => 'array',
  157. 'items' => [
  158. 'type' => 'object',
  159. 'properties' => [
  160. 'columns' => [
  161. 'type' => 'array',
  162. 'items' => ['type' => 'string'],
  163. ],
  164. 'rows' => [
  165. 'type' => 'array',
  166. 'items' => [
  167. 'type' => 'array',
  168. 'items' => ['type' => ['string', 'null']],
  169. ],
  170. ],
  171. 'affected' => ['type' => 'integer'],
  172. 'truncated' => ['type' => 'boolean'],
  173. ],
  174. ],
  175. ],
  176. ],
  177. 'required' => ['results'],
  178. ],
  179. 'annotations' => [
  180. 'title' => 'Query UNA database',
  181. 'readOnlyHint' => false,
  182. 'destructiveHint' => true,
  183. 'idempotentHint' => false,
  184. 'openWorldHint' => false,
  185. ],
  186. ];
  187. }
  188. /**
  189. * @return array{error: array{0: int, 1: string}}|array{result: array}
  190. */
  191. function callTool(object $oParams): array
  192. {
  193. if (!isset($oParams->name) || !is_string($oParams->name) || $oParams->name === '')
  194. return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: tool name is required']];
  195. if ($oParams->name !== 'query')
  196. return ['error' => [JSONRPC_INVALID_PARAMS, 'Unknown tool: ' . $oParams->name]];
  197. if (isset($oParams->arguments) && !is_object($oParams->arguments))
  198. return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: arguments must be an object']];
  199. $mSql = isset($oParams->arguments) ? ($oParams->arguments->sql ?? null) : null;
  200. if (!is_string($mSql) || trim($mSql) === '') {
  201. return ['result' => toolError('sql must be a non-empty string')];
  202. }
  203. try {
  204. $aResults = runSql($mSql);
  205. }
  206. catch (DbCallException $oException) {
  207. return ['result' => toolError($oException->getMessage(), $oException->aResults)];
  208. }
  209. catch (RuntimeException $oException) {
  210. return ['result' => toolError($oException->getMessage())];
  211. }
  212. return ['result' => [
  213. 'content' => [[
  214. 'type' => 'text',
  215. 'text' => formatResults($aResults),
  216. ]],
  217. 'structuredContent' => ['results' => $aResults],
  218. 'isError' => false,
  219. ]];
  220. }
  221. function toolError(string $sMessage, array $aResults = []): array
  222. {
  223. if ($aResults !== [])
  224. $sMessage .= "\n\n" . formatResults($aResults);
  225. $aError = [
  226. 'content' => [[
  227. 'type' => 'text',
  228. 'text' => $sMessage,
  229. ]],
  230. 'isError' => true,
  231. ];
  232. if ($aResults !== [])
  233. $aError['structuredContent'] = ['results' => $aResults];
  234. return $aError;
  235. }
  236. class DbCallException extends RuntimeException
  237. {
  238. /** @var list<array<string, mixed>> */
  239. public array $aResults;
  240. /**
  241. * @param list<array<string, mixed>> $aResults
  242. */
  243. public function __construct(string $sMessage, array $aResults)
  244. {
  245. parent::__construct($sMessage);
  246. $this->aResults = $aResults;
  247. }
  248. }
  249. /**
  250. * @return list<array{columns?: list<string>, rows?: list<list<string|null>>, affected?: int, truncated?: bool}>
  251. */
  252. function runSql(string $sSql): array
  253. {
  254. $aStatements = splitSqlStatements($sSql);
  255. if ($aStatements === [])
  256. throw new RuntimeException('sql must contain a statement');
  257. $oDb = dbConnect();
  258. try {
  259. if (dbInTransaction($oDb))
  260. $oDb->rollback();
  261. }
  262. catch (RuntimeException $oException) {
  263. dbDisconnect();
  264. $oDb = dbConnect();
  265. }
  266. dbBegin($oDb);
  267. $aPending = [];
  268. $aCommitted = [];
  269. try {
  270. foreach ($aStatements as $sStatement) {
  271. $bRestart = statementRestartsTransaction($sStatement);
  272. $aStatementResults = runStatement($oDb, $sStatement);
  273. if ($bRestart) {
  274. $aCommitted = array_merge($aCommitted, $aPending);
  275. $aPending = [];
  276. }
  277. if (!dbInTransaction($oDb)) {
  278. if (!$bRestart && statementDiscardsPending($sStatement))
  279. $aPending = [];
  280. else
  281. $aCommitted = array_merge($aCommitted, $aPending, $aStatementResults);
  282. $aPending = [];
  283. dbBegin($oDb);
  284. }
  285. else {
  286. foreach ($aStatementResults as $aResult)
  287. $aPending[] = $aResult;
  288. }
  289. }
  290. if (dbInTransaction($oDb))
  291. dbCommit($oDb);
  292. }
  293. catch (RuntimeException $oException) {
  294. $bOpen = false;
  295. try {
  296. $bOpen = dbInTransaction($oDb);
  297. }
  298. catch (RuntimeException $oIgnored) {
  299. dbDisconnect();
  300. $aMaybe = array_merge($aCommitted, $aPending);
  301. if ($aMaybe !== []) {
  302. throw new DbCallException(
  303. $oException->getMessage() . "\nThe connection closed before the outcome of earlier statements could be confirmed.",
  304. $aMaybe
  305. );
  306. }
  307. throw $oException;
  308. }
  309. if ($bOpen) {
  310. try {
  311. $oDb->rollback();
  312. }
  313. catch (mysqli_sql_exception $oRollback) {
  314. dbDisconnect();
  315. }
  316. if ($aCommitted !== []) {
  317. throw new DbCallException(
  318. $oException->getMessage() . "\nEarlier statements that already committed are listed below. The rest of this call was rolled back.",
  319. $aCommitted
  320. );
  321. }
  322. if ($aPending !== [])
  323. throw new RuntimeException($oException->getMessage() . "\nEarlier statements in this call were rolled back.");
  324. throw $oException;
  325. }
  326. $aCommitted = array_merge($aCommitted, $aPending);
  327. if ($aCommitted !== []) {
  328. throw new DbCallException(
  329. $oException->getMessage() . "\nEarlier statements already committed on the live database.",
  330. $aCommitted
  331. );
  332. }
  333. throw $oException;
  334. }
  335. return array_merge($aCommitted, $aPending);
  336. }
  337. /**
  338. * @return list<array{columns?: list<string>, rows?: list<list<string|null>>, affected?: int, truncated?: bool}>
  339. */
  340. function runStatement(mysqli $oDb, string $sSql): array
  341. {
  342. try {
  343. $oDb->real_query($sSql);
  344. }
  345. catch (mysqli_sql_exception $oException) {
  346. throw new RuntimeException($oException->getMessage());
  347. }
  348. $aResults = [];
  349. do {
  350. $oResult = $oDb->use_result();
  351. if ($oResult instanceof mysqli_result)
  352. $aResults[] = fetchBounded($oDb, $oResult);
  353. elseif ($oDb->errno)
  354. throw new RuntimeException($oDb->error !== '' ? $oDb->error : 'Query failed');
  355. elseif ($oDb->field_count === 0)
  356. $aResults[] = ['affected' => $oDb->affected_rows];
  357. } while ($oDb->more_results() && nextDbResult($oDb));
  358. if ($oDb->errno)
  359. throw new RuntimeException($oDb->error !== '' ? $oDb->error : 'Query failed');
  360. return $aResults;
  361. }
  362. /**
  363. * @return array{columns: list<string>, rows: list<list<string|null>>, truncated?: bool}
  364. */
  365. function fetchBounded(mysqli $oDb, mysqli_result $oResult): array
  366. {
  367. $aColumns = [];
  368. foreach ($oResult->fetch_fields() as $oField)
  369. $aColumns[] = $oField->name;
  370. $aRows = [];
  371. $bTruncated = false;
  372. while ($aRow = $oResult->fetch_row()) {
  373. if (count($aRows) >= MAX_RESULT_ROWS) {
  374. $bTruncated = true;
  375. break;
  376. }
  377. $aCells = [];
  378. foreach ($aRow as $mValue)
  379. $aCells[] = $mValue === null ? null : (string)$mValue;
  380. $aRows[] = $aCells;
  381. }
  382. if ($bTruncated)
  383. releaseResult($oDb, $oResult);
  384. else
  385. $oResult->free();
  386. $aResult = ['columns' => $aColumns, 'rows' => $aRows];
  387. if ($bTruncated)
  388. $aResult['truncated'] = true;
  389. return $aResult;
  390. }
  391. function releaseResult(mysqli $oDb, mysqli_result $oResult): void
  392. {
  393. try {
  394. $oKiller = dbOpen();
  395. try {
  396. $oKiller->query('KILL QUERY ' . (int)$oDb->thread_id);
  397. }
  398. catch (mysqli_sql_exception $oException) {
  399. }
  400. $oKiller->close();
  401. }
  402. catch (RuntimeException $oException) {
  403. }
  404. try {
  405. $oResult->free();
  406. }
  407. catch (mysqli_sql_exception $oException) {
  408. $bAlive = false;
  409. try {
  410. $bAlive = $oDb->ping();
  411. }
  412. catch (mysqli_sql_exception $oPing) {
  413. $bAlive = false;
  414. }
  415. if (!$bAlive) {
  416. dbDisconnect();
  417. throw new RuntimeException($oException->getMessage());
  418. }
  419. }
  420. }
  421. /**
  422. * @return list<string>
  423. */
  424. function splitSqlStatements(string $sSql): array
  425. {
  426. $aChars = mb_str_split($sSql);
  427. $iLen = count($aChars);
  428. $aStatements = [];
  429. $sCurrent = '';
  430. $sMode = '';
  431. $sQuote = '';
  432. for ($i = 0; $i < $iLen; $i++) {
  433. $sChar = $aChars[$i];
  434. $sNext = $i + 1 < $iLen ? $aChars[$i + 1] : '';
  435. if ($sMode === 'line') {
  436. $sCurrent .= $sChar;
  437. if ($sChar === "\n" || $sChar === "\r")
  438. $sMode = '';
  439. continue;
  440. }
  441. if ($sMode === 'block') {
  442. $sCurrent .= $sChar;
  443. if ($sChar === '*' && $sNext === '/') {
  444. $sCurrent .= $sNext;
  445. $i++;
  446. $sMode = '';
  447. }
  448. continue;
  449. }
  450. if ($sMode === 'quote' || $sMode === 'ident') {
  451. $sCurrent .= $sChar;
  452. if ($sMode === 'quote' && $sChar === '\\' && $sNext !== '') {
  453. $sCurrent .= $sNext;
  454. $i++;
  455. continue;
  456. }
  457. if ($sChar === $sQuote) {
  458. if ($sNext === $sQuote) {
  459. $sCurrent .= $sNext;
  460. $i++;
  461. continue;
  462. }
  463. $sMode = '';
  464. }
  465. continue;
  466. }
  467. if ($sChar === "'" || $sChar === '"') {
  468. $sMode = 'quote';
  469. $sQuote = $sChar;
  470. $sCurrent .= $sChar;
  471. continue;
  472. }
  473. if ($sChar === '`') {
  474. $sMode = 'ident';
  475. $sQuote = '`';
  476. $sCurrent .= $sChar;
  477. continue;
  478. }
  479. if ($sChar === '#') {
  480. $sMode = 'line';
  481. $sCurrent .= $sChar;
  482. continue;
  483. }
  484. if ($sChar === '-' && $sNext === '-') {
  485. $sThird = $i + 2 < $iLen ? $aChars[$i + 2] : '';
  486. if ($sThird === '' || preg_match('/\s/u', $sThird) === 1) {
  487. $sMode = 'line';
  488. $sCurrent .= $sChar;
  489. continue;
  490. }
  491. }
  492. if ($sChar === '/' && $sNext === '*') {
  493. $sMode = 'block';
  494. $sCurrent .= $sChar;
  495. continue;
  496. }
  497. if ($sChar === ';') {
  498. if (sqlWithoutLeadingComments($sCurrent) !== '')
  499. $aStatements[] = $sCurrent;
  500. $sCurrent = '';
  501. continue;
  502. }
  503. $sCurrent .= $sChar;
  504. }
  505. if (sqlWithoutLeadingComments($sCurrent) !== '')
  506. $aStatements[] = $sCurrent;
  507. return $aStatements;
  508. }
  509. function sqlWithoutLeadingComments(string $sSql): string
  510. {
  511. $s = $sSql;
  512. do {
  513. $sNext = preg_replace(
  514. '/\A(?:\s+|\/\*[\s\S]*?\*\/|#.*(?:\n|$)|--[ \t].*(?:\n|$)|--(?:\r\n|\n|\r|$))/u',
  515. '',
  516. $s,
  517. 1,
  518. $iCount
  519. );
  520. if (!is_string($sNext))
  521. return trim($s);
  522. $s = $sNext;
  523. } while ($iCount > 0);
  524. return $s;
  525. }
  526. function statementRestartsTransaction(string $sSql): bool
  527. {
  528. $s = sqlWithoutLeadingComments($sSql);
  529. if (preg_match('/^BEGIN\b(?!\s+NOT\b)/i', $s) === 1)
  530. return true;
  531. return preg_match('/^START\s+TRANSACTION\b/i', $s) === 1;
  532. }
  533. function statementDiscardsPending(string $sSql): bool
  534. {
  535. $s = sqlWithoutLeadingComments($sSql);
  536. return preg_match('/^ROLLBACK\b(?!\s+TO\b)/i', $s) === 1;
  537. }
  538. function dbBegin(mysqli $oDb): void
  539. {
  540. try {
  541. $oDb->begin_transaction();
  542. }
  543. catch (mysqli_sql_exception $oException) {
  544. throw new RuntimeException($oException->getMessage());
  545. }
  546. }
  547. function dbCommit(mysqli $oDb): void
  548. {
  549. try {
  550. $oDb->commit();
  551. }
  552. catch (mysqli_sql_exception $oException) {
  553. try {
  554. $oDb->rollback();
  555. }
  556. catch (mysqli_sql_exception $oRollback) {
  557. dbDisconnect();
  558. }
  559. throw new RuntimeException($oException->getMessage());
  560. }
  561. }
  562. function dbInTransaction(mysqli $oDb): bool
  563. {
  564. try {
  565. $oResult = $oDb->query('SELECT @@SESSION.in_transaction');
  566. }
  567. catch (mysqli_sql_exception $oException) {
  568. throw new RuntimeException($oException->getMessage());
  569. }
  570. $aRow = $oResult->fetch_row();
  571. $oResult->free();
  572. return isset($aRow[0]) && $aRow[0] === '1';
  573. }
  574. function nextDbResult(mysqli $oDb): bool
  575. {
  576. try {
  577. return $oDb->next_result();
  578. }
  579. catch (mysqli_sql_exception $oException) {
  580. throw new RuntimeException($oException->getMessage());
  581. }
  582. }
  583. /**
  584. * @param list<array{columns?: list<string>, rows?: list<list<string|null>>, affected?: int}> $aResults
  585. */
  586. function formatResults(array $aResults): string
  587. {
  588. $aBlocks = [];
  589. foreach ($aResults as $aResult) {
  590. if (isset($aResult['affected'])) {
  591. $aBlocks[] = "affected\t" . $aResult['affected'];
  592. continue;
  593. }
  594. $aLines = [implode("\t", $aResult['columns'])];
  595. foreach ($aResult['rows'] as $aRow) {
  596. $aCells = [];
  597. foreach ($aRow as $mValue) {
  598. if ($mValue === null)
  599. $aCells[] = 'NULL';
  600. else
  601. $aCells[] = str_replace(["\t", "\r", "\n"], ['\\t', '\\r', '\\n'], $mValue);
  602. }
  603. $aLines[] = implode("\t", $aCells);
  604. }
  605. if (!empty($aResult['truncated']))
  606. $aLines[] = '(truncated to ' . count($aResult['rows']) . ' rows)';
  607. $aBlocks[] = implode("\n", $aLines);
  608. }
  609. return implode("\n\n", $aBlocks);
  610. }
  611. function &dbStoredLink(): ?mysqli
  612. {
  613. static $oDb = null;
  614. return $oDb;
  615. }
  616. function dbDisconnect(): void
  617. {
  618. $oDb = &dbStoredLink();
  619. if ($oDb instanceof mysqli) {
  620. try {
  621. $oDb->close();
  622. }
  623. catch (mysqli_sql_exception $oException) {
  624. }
  625. }
  626. $oDb = null;
  627. }
  628. function dbConnect(): mysqli
  629. {
  630. $oDb = &dbStoredLink();
  631. if ($oDb instanceof mysqli) {
  632. try {
  633. if ($oDb->ping())
  634. return $oDb;
  635. }
  636. catch (mysqli_sql_exception $oException) {
  637. $oDb = null;
  638. }
  639. }
  640. $oDb = dbOpen();
  641. return $oDb;
  642. }
  643. function dbOpen(): mysqli
  644. {
  645. $sHeaderPath = dirname(__DIR__) . '/inc/header.inc.php';
  646. if (!is_readable($sHeaderPath))
  647. throw new RuntimeException('UNA is not installed (inc/header.inc.php is missing)');
  648. $aDb = dbCredentials(file_get_contents($sHeaderPath));
  649. mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
  650. $oLink = mysqli_init();
  651. $iPort = ($aDb['port'] === '' || $aDb['port'] === '0') ? 3306 : (int)$aDb['port'];
  652. try {
  653. $oLink->real_connect(
  654. $aDb['host'],
  655. $aDb['user'],
  656. $aDb['pass'],
  657. $aDb['name'],
  658. $iPort,
  659. $aDb['sock'] !== '' ? $aDb['sock'] : null
  660. );
  661. $oLink->set_charset('utf8mb4');
  662. $oLink->query("SET NAMES utf8mb4 COLLATE " . $aDb['collate']);
  663. $oLink->query("SET sql_mode = ''");
  664. }
  665. catch (mysqli_sql_exception $oException) {
  666. throw new RuntimeException('Database connect failed: ' . $oException->getMessage());
  667. }
  668. return $oLink;
  669. }
  670. /**
  671. * @return array{host: string, sock: string, port: string, user: string, pass: string, name: string, collate: string}
  672. */
  673. function dbCredentials(string $sHeader): array
  674. {
  675. $aMap = [
  676. 'host' => ['BX_DATABASE_HOST', 'UNA_DB_HOST'],
  677. 'sock' => ['BX_DATABASE_SOCK', 'UNA_DB_SOCK'],
  678. 'port' => ['BX_DATABASE_PORT', 'UNA_DB_PORT'],
  679. 'user' => ['BX_DATABASE_USER', 'UNA_DB_USER'],
  680. 'pass' => ['BX_DATABASE_PASS', 'UNA_DB_PWD'],
  681. 'name' => ['BX_DATABASE_NAME', 'UNA_DB_NAME'],
  682. ];
  683. $aDb = [];
  684. foreach ($aMap as $sKey => $aPair) {
  685. [$sConst, $sEnv] = $aPair;
  686. $sFromEnv = getenv($sEnv);
  687. if ($sFromEnv !== false) {
  688. $aDb[$sKey] = $sFromEnv;
  689. continue;
  690. }
  691. $sPattern = "/define\\(\\s*'" . $sConst . "'\\s*,\\s*\\\$_ENV\\['" . preg_quote($sEnv, '/') . "'\\]\\s*\\?\\?\\s*'((?:\\\\'|\\\\\\\\|[^'])*)'\\s*\\)/";
  692. if (!preg_match($sPattern, $sHeader, $aMatch))
  693. throw new RuntimeException("Cannot read $sConst from inc/header.inc.php");
  694. $aDb[$sKey] = str_replace(["\\'", "\\\\"], ["'", "\\"], $aMatch[1]);
  695. }
  696. foreach (['host', 'user', 'name'] as $sKey) {
  697. if ($aDb[$sKey] === '' || preg_match('/%[A-Z0-9_]+%/', $aDb[$sKey]))
  698. throw new RuntimeException('UNA is not installed (database settings in inc/header.inc.php are empty)');
  699. }
  700. $sCollate = getenv('UNA_DATABASE_COLLATE');
  701. $aDb['collate'] = ($sCollate !== false && $sCollate !== '') ? $sCollate : 'utf8mb4_unicode_ci';
  702. if (!preg_match('/^[A-Za-z0-9_]+$/', $aDb['collate']))
  703. throw new RuntimeException('Invalid database collation');
  704. return $aDb;
  705. }
  706. /**
  707. * @param string|int|null $mId
  708. * @param array<string, mixed> $aResult
  709. */
  710. function writeResult(string|int|null $mId, array $aResult): void
  711. {
  712. $aResult['resultType'] = 'complete';
  713. $aResult['_meta'] = [
  714. 'io.modelcontextprotocol/serverInfo' => [
  715. 'name' => MCP_SERVER_NAME,
  716. 'version' => MCP_SERVER_VERSION,
  717. ],
  718. ];
  719. writeMessage([
  720. 'jsonrpc' => '2.0',
  721. 'id' => $mId,
  722. 'result' => $aResult,
  723. ]);
  724. }
  725. /**
  726. * @param string|int|null $mId
  727. */
  728. function writeError(string|int|null $mId, int $iCode, string $sMessage, ?array $aData = null): void
  729. {
  730. $aError = [
  731. 'code' => $iCode,
  732. 'message' => $sMessage,
  733. ];
  734. if ($aData !== null)
  735. $aError['data'] = $aData;
  736. $aMessage = ['jsonrpc' => '2.0'];
  737. if ($mId !== null)
  738. $aMessage['id'] = $mId;
  739. $aMessage['error'] = $aError;
  740. writeMessage($aMessage);
  741. }
  742. function writeMessage(array $aMessage): void
  743. {
  744. $sJson = json_encode($aMessage, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
  745. fwrite(STDOUT, $sJson . "\n");
  746. fflush(STDOUT);
  747. }
  748. function usage(): string
  749. {
  750. return "Usage: php tools/db.php\n"
  751. . "Reads newline-delimited JSON-RPC (MCP 2026-07-28) from stdin and writes responses to stdout.\n"
  752. . "Methods: server/discover, tools/list, tools/call (tool name: query, argument: sql).\n"
  753. . "\n"
  754. . " docker compose exec -T -w /opt/una php php tools/db.php <<'EOF'\n"
  755. . " {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"query\",\"arguments\":{\"sql\":\"SELECT 1\"},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n"
  756. . " EOF\n";
  757. }