Просмотр исходного кода

AGENTS.md reorganisation (#5793)

* AGENTS.md reorganisation

* remake add lang tool

* db tool fixes

* PR requirements

* fix build

* AGENTS.md: the Next.js app is neo/apps/next, not nextjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Andrey Yasko <ay@una.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Aleksandr Trofimov 1 неделя назад
Родитель
Сommit
20e4ee7ef1

+ 24 - 0
.agents/skills/language-keys/SKILL.md

@@ -0,0 +1,24 @@
+---
+name: language-keys
+description: Add a missing English or Russian UNA string with tools/add_lang_key.php, an MCP server (stdio, protocol 2026-07-28) that updates the language XML, the database, and the language cache. Use when introducing a user-facing string, language key, or translation. Do not insert keys by hand into language XML, sys_localization_keys, or sys_localization_strings.
+---
+
+# Language keys
+
+Add a missing English or Russian string with `tools/add_lang_key.php`. Do not insert keys by hand into language XML, `sys_localization_keys`, or `sys_localization_strings`.
+
+The file is an MCP server (stdio, protocol `2026-07-28`). Run it in the php container from the UNA root. A `tools/call` of the `add` tool takes `language` (`en` or `ru`), `key`, and `translation`. An existing translation is left unchanged. The tool inserts the key in the right language file next to the closest existing keys (`_sys_…` to system, `_bx_posts_…` to Posts, and so on), inserts it into the database when that language is installed, and recompiles the language cache.
+
+Pass `module` when the key prefix does not name the module (`_bx_orgs_` belongs to `bx_organizations`).
+
+Every request includes `_meta.io.modelcontextprotocol/protocolVersion` (`2026-07-28`) and `_meta.io.modelcontextprotocol/clientCapabilities`.
+
+```bash
+docker compose exec -T -w /opt/una php php tools/add_lang_key.php <<'EOF'
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"language":"en","key":"_sys_example","translation":"Hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
+EOF
+```
+
+A translation may contain line breaks inside the JSON string. Add both `en` and `ru` when you introduce a key. Russian may be absent from the database; the Russian XML is still written (`languageInstalled` is false), and calling the tool again after Russian is installed fills the database from that file.
+
+A successful result has `content` and `structuredContent` (`file`, `xml` of `added` or `already_present`, `keptExisting`, `languageInstalled`, `keyCreated`, `translationStatus` of `added`, `already_present`, or `skipped`, `keyId`, `category`, `recompiled`). A failure comes back as `isError: true`.

+ 18 - 0
.agents/skills/studio-icons/SKILL.md

@@ -0,0 +1,18 @@
+---
+name: studio-icons
+description: How to draw UNA Studio launcher tiles (plate, flattened Lucide glyph, shadow) and which plate colours match each app category. Use when adding or editing studio/template/images/icons/wi-*.svg, a module template/images/icons/std-icon.svg, studio/template/images/modules/*.svg, or scripts/studio_icon_from_lucide.py.
+---
+
+# Studio app icons (`studio/template/images/icons/wi-*.svg`, module `template/images/icons/std-icon.svg`)
+
+Every launcher tile is one SVG with three layers, in this order:
+
+1. **Plate** – a full-size `<rect>` filled with a vertical `linearGradient`, lighter at the top, darker at the bottom (e.g. gray `#71717A → #3F3F46`, blue `#0284C7 → #1E40AF`, purple `#7C3AED → #5B21B6`).
+2. **Glyph** – the Lucide icon, flattened to one outline and filled (`fill-rule="evenodd"`) with a vertical white gradient, `white` at the top to `white` at 60% opacity at the bottom. Lucide draws a glyph as several stroked paths; layered translucent, every place two strokes overlap shows twice as dark, so expand each stroke to its outline (2 units wide, round caps and joins) and unite them first. `scripts/studio_icon_from_lucide.py` does this (shapely + svgpathtools; add the icon to its table and run it). Older tiles still carry the raw stroked paths (`stroke-linecap="round"`, `stroke-linejoin="round"`); flatten them when they are touched.
+3. **Shadow** – an exact copy of the glyph outline, drawn *after* the glyph, shifted 1px down, filled `black` with `fill-opacity="0.1"` (older stroked tiles: stroked `black` with `stroke-opacity="0.1"`).
+
+Placement: keep the outline in Lucide's 24-unit space and wrap each copy in `<g transform="translate(x y) scale(s)">` so the 24-unit box lands centred on the plate with a stroke width of 2 units (which scales to 4px on a 72px canvas at `scale(2)`, or 4.4px on an 80px canvas at `scale(2.2)`). The shadow group uses the same transform with `y + 1`. Studio's own tiles are 72×72 (`translate(12 12) scale(2)`, shadow `translate(12 13)`); newer module icons are 80×80 (`translate(13.6 13.6) scale(2.2)`, shadow `translate(13.6 14.6)`). Gradient `y1`/`y2` for the glyph are given in the group's own units (2 to 22).
+
+Plate colours follow the app category: gray `#71717A → #3F3F46` system, green `#059669 → #065F46` content, orange `#D97706 → #92400E` profiles, purple `#7C3AED → #5B21B6` templates, red `#F43F5E → #BE123C` contexts (groups, spaces, events, courses), white `#F3F4F6 → #E5E7EB` languages; integrations sit on white or their brand colour.
+
+Pick glyphs from https://lucide.dev; the vendored copies live in `plugins_public/lucide/icons/`. `modules/boonex/mapshow/template/images/icons/std-icon.svg` (flattened, 80px) and `studio/template/images/modules/bx_polls.svg` (flattened, 72px) are reference implementations.

+ 1 - 0
.claude/skills/language-keys

@@ -0,0 +1 @@
+../../.agents/skills/language-keys

+ 1 - 0
.claude/skills/studio-icons

@@ -0,0 +1 @@
+../../.agents/skills/studio-icons

+ 77 - 25
AGENTS.md

@@ -1,43 +1,95 @@
 # Notes for coding agents
 
-## NEO (`neo/`)
+# Project Structure
 
-`neo/` is NEO, the Next.js and Expo client for UNA, kept here as a git subtree. Before changing anything under `neo/`, read [`neo/AGENTS.md`](neo/AGENTS.md). Claude Code loads it through `neo/CLAUDE.md` when it opens a file there.
+This repository contains the UNA PHP application and the NEO frontend monorepo as `neo/` subfolder.
 
-- Run NEO's yarn scripts from `neo/`, not from the UNA root.
-- Branch from `master` and open the PR here. Start the title of a NEO-only PR with `Neo:`.
-- Never commit to unacms/neo. It is a mirror that `.github/workflows/sync-neo.yml` pushes on every push to `master`, and client projects fork it.
-- UNA CI doesn't check `neo/`. Run `yarn typecheck` and `yarn lint` in `neo/` before you push.
-- CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `pr-<n>.unacms.app` (NEO) and `api-pr-<n>.unacms.app` (UNA); master deploys production. See `.github/workflows/ci.yml` and `scripts/railway/`.
-- A UNA service that NEO calls must return JSON for guests too; see [`neo/.agents/skills/una-api/SKILL.md`](neo/.agents/skills/una-api/SKILL.md).
+## Repository layout
 
-## Studio app icons (`studio/template/images/icons/wi-*.svg`, module `template/images/icons/std-icon.svg`)
+```text
+/
+├── ...                    # UNA PHP application
+├── tests/                 # UNA PHP unit and integration tests
+├── modules/               # UNA modules
+├── tools/                 # Agent MCP tools (see tools/AGENTS.md)
+├── ...
+└── neo/                   # NEO frontend monorepo
+    ├── apps/
+    │   ├── expo/          # Expo / React Native mobile application
+    │   └── next/          # Next.js web frontend
+    ├── packages/          # Shared frontend packages
+    ├── package.json
+    └── ...
+```
 
-Every launcher tile is one SVG with three layers, in this order:
+## UNA coding restrictions
 
-1. **Plate** – a full-size `<rect>` filled with a vertical `linearGradient`, lighter at the top, darker at the bottom (e.g. gray `#71717A → #3F3F46`, blue `#0284C7 → #1E40AF`, purple `#7C3AED → #5B21B6`).
-2. **Glyph** – the Lucide icon, flattened to one outline and filled (`fill-rule="evenodd"`) with a vertical white gradient, `white` at the top to `white` at 60% opacity at the bottom. Lucide draws a glyph as several stroked paths; layered translucent, every place two strokes overlap shows twice as dark, so expand each stroke to its outline (2 units wide, round caps and joins) and unite them first. `scripts/studio_icon_from_lucide.py` does this (shapely + svgpathtools; add the icon to its table and run it). Older tiles still carry the raw stroked paths (`stroke-linecap="round"`, `stroke-linejoin="round"`); flatten them when they are touched.
-3. **Shadow** – an exact copy of the glyph outline, drawn *after* the glyph, shifted 1px down, filled `black` with `fill-opacity="0.1"` (older stroked tiles: stroked `black` with `stroke-opacity="0.1"`).
+These apply to UNA PHP (`inc/`, `modules/`, `studio/`, `template/`, and install or update SQL). They do not apply to `neo/`.
 
-Placement: keep the outline in Lucide's 24-unit space and wrap each copy in `<g transform="translate(x y) scale(s)">` so the 24-unit box lands centred on the plate with a stroke width of 2 units (which scales to 4px on a 72px canvas at `scale(2)`, or 4.4px on an 80px canvas at `scale(2.2)`). The shadow group uses the same transform with `y + 1`. Studio's own tiles are 72×72 (`translate(12 12) scale(2)`, shadow `translate(12 13)`); newer module icons are 80×80 (`translate(13.6 13.6) scale(2.2)`, shadow `translate(13.6 14.6)`). Gradient `y1`/`y2` for the glyph are given in the group's own units (2 to 22).
+- Text may contain multibyte characters. Do not use byte-oriented functions (`strlen`, `substr`, `strpos`, `strrpos`, `strtolower`, `strtoupper`, `substr_replace`) on it. Use `get_mb_len`, `get_mb_substr`, `get_mb_replace`, `bx_mb_strpos`, and `bx_mb_substr_replace` from `inc/utils.inc.php`.
+- Do not migrate the database from PHP. No `CREATE`, `ALTER`, or other schema change that runs when a page or service loads.
+- SQL belongs in a `*Db` or `*Query` class. Do not put queries in a module, template, form, page, grid or other classes.
+- HTML belongs in a template `*.html` file and is rendered with `parseHtmlByName`. Do not build markup as strings in PHP.
+- No Cyrillic characters in code, comments, or SQL. User-facing text is a language key. Errors and logs without translations is acceptable.
+- Common system classes (`BxDol*`, `BxBase*`, `BxTempl*`) are autoloaded. Do not guard them with `class_exists`, `bx_import`, or `require`. Custom modules classes need to be explicitly loaded with `bx_import`.
+- Do not hardcode a list of module names. Read installed modules from `BxDolModuleQuery`.
+- Do not call module by a literal name (`BxDolModule::getInstance('bx_posts')`, `BxDolService::call('bx_posts', ...)`, `new BxPostsModule`) without checking if module is installed `BxDolModuleQuery::getInstance()->isEnabledByName('bx_posts')`.
+- Call `getParam` only with a name that exists in `sys_options`. Declare the option in that module's install SQL before reading it.
+- Do not add or edit anything under `upgrade/files/`. That folder records past releases. Do not put new migrations there.
+- Set `active_api` in `install/sql/enable-app-pages.sql` (page blocks) and `install/sql/enable-app-menus.sql` (menu items) for core, for modules use `modules/boonex/*/install/sql/enable-app.sql` files. Do not set it in `install.sql`, `enable.sql`, or other common install and enable scripts.
 
-Plate colours follow the app category: gray `#71717A → #3F3F46` system, green `#059669 → #065F46` content, orange `#D97706 → #92400E` profiles, purple `#7C3AED → #5B21B6` templates, red `#F43F5E → #BE123C` contexts (groups, spaces, events, courses), white `#F3F4F6 → #E5E7EB` languages; integrations sit on white or their brand colour.
+## Studio app icons
 
-Pick glyphs from https://lucide.dev; the vendored copies live in `plugins_public/lucide/icons/`. `modules/boonex/mapshow/template/images/icons/std-icon.svg` (flattened, 80px) and `studio/template/images/modules/bx_polls.svg` (flattened, 72px) are reference implementations.
+Before adding or changing a launcher tile (`studio/template/images/icons/wi-*.svg`, `modules/**/template/images/icons/std-icon.svg`), read [`.agents/skills/studio-icons/SKILL.md`](.agents/skills/studio-icons/SKILL.md).
 
-## Language keys
+## Tests
 
-Add a missing English or Russian string with `scripts/add_lang_key.php`. Do not insert keys by hand into language XML, `sys_localization_keys`, or `sys_localization_strings`.
+Run UNA PHPUnit inside the `php` service from `docker compose`. That container is on the `unanet` network and reaches MariaDB at the host name stored in the installed site (`mysql`). PHPUnit is `plugins/bin/phpunit` after `composer install` (dev dependencies included). The site must already be installed (`inc/header.inc.php` present). Start the stack with `docker compose up -d` first.
 
-Run it in the php container. Arguments are language (`en` or `ru`), key, and translation. An existing translation is left unchanged. The script inserts the key in the right language file next to the closest existing keys (`_sys_…` to system, `_bx_posts_…` to Posts, and so on), inserts it into the database when that language is installed, and recompiles the language cache.
+From the repository root:
+
+Unit tests:
+
+```bash
+docker compose exec -w /opt/una php ./plugins/bin/phpunit -c tests/phpunit.xml --testsuite Units
+```
+
+Integration tests:
 
 ```bash
-docker exec -i una-php-1 php /opt/una/scripts/add_lang_key.php en _sys_example "Hello"
+docker compose exec -w /opt/una php ./plugins/bin/phpunit -c tests/phpunit.xml --testsuite Integration
+```
 
-docker exec -i una-php-1 php /opt/una/scripts/add_lang_key.php ru _sys_example - <<'EOF'
-Line one
-Line two
-EOF
+One test class or method (`--filter` matches the class or method name):
+
+```bash
+docker compose exec -w /opt/una php ./plugins/bin/phpunit -c tests/phpunit.xml --filter TestName
 ```
 
-Pass `-` as the translation to read stdin. That keeps line breaks; one trailing newline is removed. Add both `en` and `ru` when you introduce a key. Russian may be absent from the database; the Russian XML is still written, and re-running the command after Russian is installed fills the database from that file.
+Integration tests read `tests/.env` when that file exists (`cp tests/.env.example tests/.env`). Otherwise they use the installer defaults. A case skips an account that is not installed. JUnit output is written to `logs/junit.xml`.
+
+## UNA pull requests
+
+These apply to changes in UNA PHP (`inc/`, `modules/`, `studio/`, `template/`, `tests/`, and install or update SQL). They do not apply to a PR that only changes `neo/`.
+
+Before opening the pull request:
+
+- Add unit and/or integration tests for new functionality.
+- Run the affected test suites. The run must finish with no failed tests. A skipped case is acceptable only when the suite skips it because the account or module is not installed.
+- Review the change for security issues (injection, missing auth or permission checks, unsafe file or path handling, secrets in code or logs, and similar).
+- Review the change for performance issues and bottlenecks (queries in loops, unbounded result sets, repeated work per request, and similar).
+- When the change includes a database migration, apply that SQL locally with the `query` tool in `tools/db.php` (see [`tools/AGENTS.md`](tools/AGENTS.md)) before opening the pull request.
+
+The pull request description must include the database migration SQL when the change has one. When it has none, say that there is no database migration.
+
+## NEO (`neo/`)
+
+`neo/` is NEO, the Next.js and Expo client for UNA, kept here as a git subtree. Before changing anything under `neo/`, read [`neo/AGENTS.md`](neo/AGENTS.md). Claude Code loads it through `neo/CLAUDE.md` when it opens a file there.
+
+- Run NEO's yarn scripts from `neo/`, not from the UNA root.
+- Branch from `master` and open the PR here. Start the title of a NEO-only PR with `Neo:`.
+- Never commit to unacms/neo. It is a mirror that `.github/workflows/sync-neo.yml` pushes on every push to `master`, and client projects fork it.
+- UNA CI doesn't check `neo/`. Run `yarn typecheck` and `yarn lint` in `neo/` before you push.
+- CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `pr-<n>.unacms.app` (NEO) and `api-pr-<n>.unacms.app` (UNA); master deploys production. See `.github/workflows/ci.yml` and `scripts/railway/`.
+- A UNA service that NEO calls must return JSON for guests too; see [`neo/.agents/skills/una-api/SKILL.md`](neo/.agents/skills/una-api/SKILL.md).
+

+ 15 - 2
build.xml

@@ -63,6 +63,8 @@
         <exclude name="inc/header*" />
         <exclude name="AGENTS.md" />
         <exclude name="CLAUDE.md" />
+        <exclude name=".agents/**" />
+        <exclude name=".claude/**" />
         <exclude name="build.xml" />
         <exclude name="build.properties" />
         <exclude name="bower.json" />
@@ -83,14 +85,15 @@
         <exclude name="storage/**/*" />
         <exclude name="samples/**" />
         <exclude name="scripts/**" />
+        <exclude name="tools/**" />
         <exclude name="src/**" />
         <exclude name="tests/**" />
         <exclude name="packages/**" />
         <exclude name="upgrade/**" />
         <exclude name="node_modules/**" />
-        <exclude name="bower_components/**" />
-        <exclude name="neo/**" />
+        <exclude name="bower_components/**" />        
         <exclude name="modules/boonex/*/updates/**" />
+        <exclude name="neo/**" />
     </fileset>
 
     <fileset dir="${project.basedir}" defaultexcludes="true" id="doc_files">
@@ -102,6 +105,7 @@
         <exclude name="storage/**/*" />
         <exclude name="samples/**" />
         <exclude name="scripts/**" />
+        <exclude name="tools/**" />
         <exclude name="tests/**" />
         <exclude name="packages/**" />
         <exclude name="upgrade/**" />
@@ -123,6 +127,7 @@
         <exclude name="modules/boonex/xero/plugins/**" />
         <exclude name="modules/boonex/*/updates/**" />
         <exclude name="**/updates/**" />        
+        <exclude name="neo/**" />
     </fileset>
 
     <!-- ============================================  -->
@@ -201,6 +206,14 @@
             <fileset dir="${project.basedir}/packages/tmp" defaultexcludes="true" />
         </zip>
 
+        <fail message="Package archive was not created: packages/${package_file}">
+            <condition>
+                <not>
+                    <available file="${project.basedir}/packages/${package_file}" />
+                </not>
+            </condition>
+        </fail>
+
         <delete dir="${project.basedir}/packages/tmp" quiet="true" />
 
     </target>

+ 0 - 562
scripts/add_lang_key.php

@@ -1,562 +0,0 @@
-<?php
-/**
- * Add a missing language key for en or ru.
- *
- * The string is written into the module language XML (skipped when that
- * key is already there) and into the database, then the language cache
- * is recompiled. An existing translation is left unchanged.
- *
- * Usage:
- *   php scripts/add_lang_key.php <en|ru> <key> <translation>
- *   php scripts/add_lang_key.php <en|ru> <key> - <<'EOF'
- *   line one
- *   line two
- *   EOF
- *
- * A translation of "-" is read from stdin. A single trailing newline on
- * stdin is removed; newlines inside the text are kept.
- *
- * When the key prefix doesn't name the module (bx_organizations uses _bx_orgs_),
- * set ADD_LANG_KEY_MODULE=<module name>.
- *
- * Run inside the php container:
- *   docker exec -i una-php-1 php /opt/una/scripts/add_lang_key.php en _sys_example "Hello"
- */
-
-if (PHP_SAPI !== 'cli') {
-    fwrite(STDERR, "CLI only\n");
-    exit(1);
-}
-
-$sLang = isset($argv[1]) ? strtolower($argv[1]) : '';
-$sKey = isset($argv[2]) ? $argv[2] : '';
-$bFromStdin = !isset($argv[3]) || $argv[3] === '-';
-
-if (!in_array($sLang, array('en', 'ru'), true) || $sKey === '' || isset($argv[4])) {
-    fwrite(STDERR, usage());
-    exit(2);
-}
-
-if (strpbrk($sKey, "\r\n") !== false) {
-    fwrite(STDERR, "Key must be a single line\n");
-    exit(2);
-}
-
-if ($bFromStdin) {
-    $sTranslation = stream_get_contents(STDIN);
-    if ($sTranslation === false)
-        $sTranslation = '';
-    if (substr($sTranslation, -2) === "\r\n")
-        $sTranslation = substr($sTranslation, 0, -2);
-    else if (substr($sTranslation, -1) === "\n")
-        $sTranslation = substr($sTranslation, 0, -1);
-}
-else {
-    $sTranslation = $argv[3];
-}
-
-if ($sTranslation === '') {
-    fwrite(STDERR, "Translation is empty\n");
-    exit(2);
-}
-
-if (function_exists('mb_check_encoding') && !mb_check_encoding($sTranslation, 'UTF-8')) {
-    fwrite(STDERR, "Translation is not valid UTF-8\n");
-    exit(2);
-}
-
-$sRoot = dirname(__DIR__) . '/';
-$aModules = loadModules($sRoot);
-$aLangModules = loadLanguageModules($sRoot);
-
-// ADD_LANG_KEY_MODULE=<module name> picks the module when the key prefix can't tell
-// (e.g. _bx_orgs_ keys belong to bx_organizations).
-$sForced = getenv('ADD_LANG_KEY_MODULE');
-if ($sForced !== false && $sForced !== '') {
-    if (!isset($aModules[$sForced])) {
-        fwrite(STDERR, "Unknown module '$sForced'\n");
-        exit(2);
-    }
-    $aModule = $aModules[$sForced];
-}
-else {
-    $aModule = findModuleByExistingKey($sRoot, $sKey, $aModules);
-    if (!$aModule)
-        $aModule = inferModule($sKey, $aModules);
-}
-
-$sFile = resolveLangFile($sRoot, $sLang, $aModule, $aLangModules);
-$sRel = ltrim(str_replace('\\', '/', substr($sFile, strlen($sRoot))), '/');
-
-$sExisting = is_file($sFile) ? readLangString($sFile, $sKey) : null;
-if ($sExisting !== null) {
-    echo "xml: already present in $sRel\n";
-    if ($sExisting !== $sTranslation)
-        echo "kept the translation already in the language file\n";
-    $sStore = $sExisting;
-}
-else {
-    if (!writeLangString($sFile, $sLang, $sKey, $sTranslation)) {
-        fwrite(STDERR, "Failed to write $sRel\n");
-        exit(1);
-    }
-    echo "xml: added to $sRel\n";
-    $sStore = $sTranslation;
-}
-
-if (dbAddMissing($sLang, $sKey, $sStore, $aModule['language_category']) === false)
-    exit(1);
-
-exit(0);
-
-function usage()
-{
-    return <<<TXT
-Usage: php add_lang_key.php <en|ru> <key> <translation>
-       php add_lang_key.php <en|ru> <key> -    (translation from stdin, may span lines)
-
-Adds the key only when that language does not already have it.
-
-TXT;
-}
-
-function loadModules($sRoot)
-{
-    $a = array(
-        'system' => array(
-            'name' => 'system',
-            'home_dir' => 'system/',
-            'language_category' => 'System',
-        ),
-    );
-
-    foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
-        $s = file_get_contents($sConfig);
-        if ($s === false)
-            continue;
-
-        $sName = configValue($s, 'name');
-        $sHome = configValue($s, 'home_dir');
-        if ($sName === null || $sHome === null || $sName === '')
-            continue;
-
-        $sCategory = configValue($s, 'language_category');
-        if ($sCategory === null || $sCategory === '')
-            $sCategory = $sName;
-
-        $a[$sName] = array(
-            'name' => $sName,
-            'home_dir' => rtrim($sHome, '/') . '/',
-            'language_category' => $sCategory,
-        );
-    }
-
-    return $a;
-}
-
-function loadLanguageModules($sRoot)
-{
-    $a = array(
-        'en' => array('home_dir' => 'boonex/english/'),
-        'ru' => array('home_dir' => 'boonex/russian/'),
-    );
-
-    foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
-        $s = file_get_contents($sConfig);
-        if ($s === false)
-            continue;
-
-        $sUri = configValue($s, 'home_uri');
-        $sHome = configValue($s, 'home_dir');
-        if (($sUri !== 'en' && $sUri !== 'ru') || $sHome === null)
-            continue;
-
-        $a[$sUri] = array('home_dir' => rtrim($sHome, '/') . '/');
-    }
-
-    return $a;
-}
-
-function configValue($sSource, $sKey)
-{
-    $sPattern = '/[\'"]' . preg_quote($sKey, '/') . '[\'"]\s*=>\s*[\'"]([^\'"]*)[\'"]/';
-    if (!preg_match($sPattern, $sSource, $aMatch))
-        return null;
-    return $aMatch[1];
-}
-
-function findModuleByExistingKey($sRoot, $sKey, $aModules)
-{
-    $aFound = array();
-    $aFiles = array_merge(
-        glob($sRoot . 'modules/*/*/install/langs/*.xml') ?: array(),
-        glob($sRoot . 'modules/*/*/data/langs/*/*.xml') ?: array()
-    );
-
-    foreach ($aFiles as $sFile) {
-        if (!fileHasKey($sFile, $sKey))
-            continue;
-
-        $sNorm = str_replace('\\', '/', $sFile);
-        $aModule = null;
-
-        if (preg_match('#/data/langs/([^/]+)/[^/]+\.xml$#', $sNorm, $aMatch) && isset($aModules[$aMatch[1]]))
-            $aModule = $aModules[$aMatch[1]];
-        else if (preg_match('#/modules/(.+)/install/langs/[^/]+\.xml$#', $sNorm, $aMatch)) {
-            $sHome = $aMatch[1] . '/';
-            foreach ($aModules as $aCandidate) {
-                if ($aCandidate['home_dir'] === $sHome) {
-                    $aModule = $aCandidate;
-                    break;
-                }
-            }
-        }
-
-        if ($aModule)
-            $aFound[$aModule['name']] = $aModule;
-    }
-
-    if (!$aFound)
-        return null;
-
-    if (isset($aFound['system']) && count($aFound) > 1)
-        unset($aFound['system']);
-
-    return reset($aFound);
-}
-
-function inferModule($sKey, $aModules)
-{
-    $sBare = ltrim($sKey, '_');
-    $aNames = array_keys($aModules);
-    usort($aNames, function ($sA, $sB) {
-        return strlen($sB) - strlen($sA);
-    });
-
-    foreach ($aNames as $sName) {
-        if ($sName === 'system')
-            continue;
-        if ($sBare === $sName || strpos($sBare, $sName . '_') === 0)
-            return $aModules[$sName];
-    }
-
-    return $aModules['system'];
-}
-
-function resolveLangFile($sRoot, $sLang, $aModule, $aLangModules)
-{
-    $sPack = $sRoot . 'modules/' . $aLangModules[$sLang]['home_dir'] . 'data/langs/' . $aModule['name'] . '/' . $sLang . '.xml';
-    if (is_file($sPack))
-        return $sPack;
-
-    if ($aModule['name'] !== 'system') {
-        $sInstall = $sRoot . 'modules/' . $aModule['home_dir'] . 'install/langs/' . $sLang . '.xml';
-        if (is_file($sInstall) || $sLang === 'en')
-            return $sInstall;
-    }
-
-    return $sPack;
-}
-
-function fileHasKey($sPath, $sKey)
-{
-    return readLangString($sPath, $sKey) !== null;
-}
-
-function readLangString($sPath, $sKey)
-{
-    $s = @file_get_contents($sPath);
-    if ($s === false)
-        return null;
-
-    $aVariants = array_unique(array($sKey, htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8')));
-    foreach ($aVariants as $sVariant) {
-        $sPattern = '/<string\b[^>]*\bname=(["\'])' . preg_quote($sVariant, '/') . '\\1[^>]*>(.*?)<\/string>/s';
-        if (!preg_match($sPattern, $s, $aMatch))
-            continue;
-
-        $sInner = $aMatch[2];
-        if (preg_match('/^\s*<!\[CDATA\[(.*)\]\]>\s*$/s', $sInner, $aCdata))
-            return str_replace(']]]]><![CDATA[>', ']]>', $aCdata[1]);
-
-        return html_entity_decode($sInner, ENT_QUOTES | ENT_XML1, 'UTF-8');
-    }
-
-    return null;
-}
-
-function writeLangString($sFile, $sLang, $sKey, $sTranslation)
-{
-    $aMeta = array(
-        'en' => array('flag' => 'gb', 'title' => 'English'),
-        'ru' => array('flag' => 'ru', 'title' => 'Russian'),
-    );
-
-    $sNl = "\n";
-    if (is_file($sFile)) {
-        $sXml = file_get_contents($sFile);
-        if ($sXml === false)
-            return false;
-        if (strpos($sXml, "\r\n") !== false)
-            $sNl = "\r\n";
-    }
-    else {
-        $sDir = dirname($sFile);
-        if (!is_dir($sDir) && !mkdir($sDir, 0775, true))
-            return false;
-
-        $sXml = '<?xml version="1.0" encoding="utf-8"?>' . $sNl
-            . '<resources name="' . $sLang . '" flag="' . $aMeta[$sLang]['flag'] . '" title="' . $aMeta[$sLang]['title'] . '">' . $sNl
-            . '</resources>' . $sNl;
-    }
-
-    $iPos = insertionOffset($sXml, $sKey);
-    if ($iPos === false)
-        return false;
-
-    $sIndent = indentAt($sXml, $iPos);
-    $sLine = $sIndent . '<string name="' . htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8') . '"><![CDATA[' . cdata($sTranslation) . ']]></string>' . $sNl;
-    if ($iPos > 0 && $sXml[$iPos - 1] !== "\n")
-        $sLine = $sNl . $sLine;
-
-    $sXml = substr($sXml, 0, $iPos) . $sLine . substr($sXml, $iPos);
-    return file_put_contents($sFile, $sXml, LOCK_EX) !== false;
-}
-
-/**
- * Byte offset where $sKey should be inserted: after the closest earlier
- * sibling, or before </resources> when nothing shares a meaningful prefix.
- * Sibling groups stay intact, so a new key lands beside a family rather
- * than between two keys that belong together.
- */
-function insertionOffset($sXml, $sKey)
-{
-    $iFallback = strrpos($sXml, '</resources>');
-    if ($iFallback === false)
-        return false;
-
-    $aEntries = langStringEntries($sXml);
-    if (!$aEntries)
-        return $iFallback;
-
-    $iBest = 0;
-    foreach ($aEntries as $aEntry) {
-        $iScore = commonNonEmptySegments($sKey, $aEntry['name']);
-        if ($iScore > $iBest)
-            $iBest = $iScore;
-    }
-    if ($iBest < 2)
-        return $iFallback;
-
-    $aClose = array();
-    foreach ($aEntries as $i => $aEntry) {
-        if (commonNonEmptySegments($sKey, $aEntry['name']) === $iBest)
-            $aClose[$i] = $aEntry;
-    }
-
-    $iPred = null;
-    $sPredName = null;
-    foreach ($aClose as $i => $aEntry) {
-        if (strcmp($aEntry['name'], $sKey) < 0 && ($sPredName === null || strcmp($aEntry['name'], $sPredName) > 0)) {
-            $iPred = $i;
-            $sPredName = $aEntry['name'];
-        }
-    }
-    if ($iPred !== null)
-        return clusterEdge($aEntries, $iPred, $iBest, false);
-
-    $iSucc = null;
-    $sSuccName = null;
-    foreach ($aClose as $i => $aEntry) {
-        if (strcmp($aEntry['name'], $sKey) > 0 && ($sSuccName === null || strcmp($aEntry['name'], $sSuccName) < 0)) {
-            $iSucc = $i;
-            $sSuccName = $aEntry['name'];
-        }
-    }
-    if ($iSucc !== null)
-        return clusterEdge($aEntries, $iSucc, $iBest, true);
-
-    return $iFallback;
-}
-
-function clusterEdge($aEntries, $iAnchor, $iMinScore, $bStart)
-{
-    $sAnchor = $aEntries[$iAnchor]['name'];
-    $iEdge = $iAnchor;
-
-    if ($bStart) {
-        for ($i = $iAnchor - 1; $i >= 0; $i--) {
-            if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
-                break;
-            $iEdge = $i;
-        }
-        return $aEntries[$iEdge]['start'];
-    }
-
-    $iCount = count($aEntries);
-    for ($i = $iAnchor + 1; $i < $iCount; $i++) {
-        if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
-            break;
-        $iEdge = $i;
-    }
-    return $aEntries[$iEdge]['end'];
-}
-
-function langStringEntries($sXml)
-{
-    $aEntries = array();
-    $sPattern = '/^([ \t]*)<string\b[^>]*\bname=(["\'])(.*?)\2[^>]*>.*?<\/string>[ \t]*(?:\r?\n)?/ms';
-    if (!preg_match_all($sPattern, $sXml, $aMatches, PREG_OFFSET_CAPTURE))
-        return $aEntries;
-
-    foreach ($aMatches[0] as $i => $aFull) {
-        $aEntries[] = array(
-            'name' => html_entity_decode($aMatches[3][$i][0], ENT_QUOTES | ENT_XML1, 'UTF-8'),
-            'indent' => $aMatches[1][$i][0],
-            'start' => $aFull[1],
-            'end' => $aFull[1] + strlen($aFull[0]),
-        );
-    }
-    return $aEntries;
-}
-
-function commonNonEmptySegments($sA, $sB)
-{
-    $aA = explode('_', $sA);
-    $aB = explode('_', $sB);
-    $n = min(count($aA), count($aB));
-    $iNonEmpty = 0;
-    for ($i = 0; $i < $n; $i++) {
-        if ($aA[$i] !== $aB[$i])
-            break;
-        if ($aA[$i] !== '')
-            $iNonEmpty++;
-    }
-    return $iNonEmpty;
-}
-
-function indentAt($sXml, $iPos)
-{
-    $aEntries = langStringEntries($sXml);
-    $sIndent = '    ';
-    foreach ($aEntries as $aEntry) {
-        if ($aEntry['start'] <= $iPos)
-            $sIndent = $aEntry['indent'];
-        if ($aEntry['start'] >= $iPos)
-            return $aEntry['indent'] !== '' ? $aEntry['indent'] : $sIndent;
-    }
-    return $sIndent;
-}
-
-function cdata($s)
-{
-    return str_replace(']]>', ']]]]><![CDATA[>', $s);
-}
-
-function dbAddMissing($sLang, $sKey, $sTranslation, $sCategory)
-{
-    bootUna();
-
-    $oDb = BxDolDb::getInstance();
-    $aLang = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_languages` WHERE `Name` = ? LIMIT 1", $sLang));
-    if (!$aLang) {
-        fwrite(STDERR, "Language '$sLang' is not installed in the database\n");
-        return false;
-    }
-    $iLangId = (int)$aLang['id'];
-
-    $aKey = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_keys` WHERE `Key` = ? LIMIT 1", $sKey));
-    if (!$aKey) {
-        $iCategoryId = categoryId($oDb, $sCategory);
-        if (!$iCategoryId) {
-            fwrite(STDERR, "Could not resolve language category '$sCategory'\n");
-            return false;
-        }
-
-        $iInserted = (int)$oDb->query($oDb->prepare(
-            "INSERT INTO `sys_localization_keys` (`IDCategory`, `Key`) VALUES (?, ?)",
-            $iCategoryId,
-            $sKey
-        ));
-        if ($iInserted <= 0) {
-            fwrite(STDERR, "Could not insert language key\n");
-            return false;
-        }
-        $iKeyId = (int)$oDb->lastId();
-        echo "db: created key $iKeyId in '$sCategory'\n";
-    }
-    else {
-        $iKeyId = (int)$aKey['id'];
-    }
-
-    $aString = $oDb->getRow($oDb->prepare(
-        "SELECT `IDKey` AS `id` FROM `sys_localization_strings` WHERE `IDKey` = ? AND `IDLanguage` = ? LIMIT 1",
-        $iKeyId,
-        $iLangId
-    ));
-    if ($aString) {
-        echo "db: already present for $sLang\n";
-        return true;
-    }
-
-    $iInserted = (int)$oDb->query($oDb->prepare(
-        "INSERT INTO `sys_localization_strings` (`IDKey`, `IDLanguage`, `String`) VALUES (?, ?, ?)",
-        $iKeyId,
-        $iLangId,
-        $sTranslation
-    ));
-    if ($iInserted <= 0) {
-        fwrite(STDERR, "Could not insert translation\n");
-        return false;
-    }
-
-    bx_import('BxDolStudioLanguagesUtils');
-    BxDolLanguages::getInstance();
-    if (!BxDolStudioLanguagesUtils::getInstance()->compileLanguage($iLangId, true)) {
-        fwrite(STDERR, "Translation stored, but the language cache was not recompiled\n");
-        return false;
-    }
-
-    echo "db: added translation for $sLang and recompiled\n";
-    return true;
-}
-
-function categoryId($oDb, $sCategory)
-{
-    $aCategory = $oDb->getRow($oDb->prepare(
-        "SELECT `ID` AS `id` FROM `sys_localization_categories` WHERE `Name` = ? LIMIT 1",
-        $sCategory
-    ));
-    if ($aCategory)
-        return (int)$aCategory['id'];
-
-    $iInserted = (int)$oDb->query($oDb->prepare(
-        "INSERT INTO `sys_localization_categories` (`Name`) VALUES (?)",
-        $sCategory
-    ));
-    if ($iInserted <= 0)
-        return 0;
-
-    return (int)$oDb->lastId();
-}
-
-function bootUna()
-{
-    $GLOBALS['bx_profiler_disable'] = true;
-    if (!defined('BX_DOL_CRON_EXECUTE'))
-        define('BX_DOL_CRON_EXECUTE', '1');
-
-    $_ENV['UNA_SKIP_REDIRECT'] = '1';
-    $_ENV['UNA_SKIP_MINIMAL_REQUIREMENTS'] = '1';
-    $_ENV['UNA_SKIP_INSTALL_FOLDER_CHECK'] = '1';
-
-    if (empty($_SERVER['HTTP_HOST'])) {
-        $_SERVER['HTTP_HOST'] = 'hihi.com';
-        $_SERVER['REQUEST_URI'] = '/una/';
-        $_SERVER['SERVER_NAME'] = 'hihi.com';
-        $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
-    }
-
-    require_once dirname(__DIR__) . '/inc/header.inc.php';
-    require_once BX_DIRECTORY_PATH_INC . 'design.inc.php';
-}

+ 20 - 0
tests/README.md

@@ -72,3 +72,23 @@ composer test-integration
 JUnit report: `logs/junit-integration.xml`.
 
 Integration tests read `tests/.env` when present (`cp tests/.env.example tests/.env`). Without that file they use installer defaults. Tests skip an account that is not installed.
+
+DOCKER
+======
+
+Run the same suites inside the `php` service so PHP can reach MariaDB at host `mysql`. From the repository root, with `docker compose up -d` and UNA already installed:
+
+Unit tests:
+```
+docker compose exec -w /opt/una php ./plugins/bin/phpunit -c tests/phpunit.xml --testsuite Units
+```
+
+Integration tests:
+```
+docker compose exec -w /opt/una php ./plugins/bin/phpunit -c tests/phpunit.xml --testsuite Integration
+```
+
+One test class or method:
+```
+docker compose exec -w /opt/una php ./plugins/bin/phpunit -c tests/phpunit.xml --filter TestName
+```

+ 31 - 0
tools/AGENTS.md

@@ -0,0 +1,31 @@
+# Tools
+
+CLI MCP servers for coding agents. Run them inside the `php` service from the repository root, with the stack up (`docker compose up -d`).
+
+Each file is an MCP server (stdio, protocol `2026-07-28`). Messages are one JSON-RPC object per line. Every request includes `_meta.io.modelcontextprotocol/protocolVersion` (`2026-07-28`) and `_meta.io.modelcontextprotocol/clientCapabilities`. The server answers `server/discover`, `tools/list`, and `tools/call`.
+
+## Language keys
+
+`add_lang_key.php` adds a missing English or Russian string. Do not insert keys by hand into language XML, `sys_localization_keys`, or `sys_localization_strings`. Read [`.agents/skills/language-keys/SKILL.md`](../.agents/skills/language-keys/SKILL.md) before adding one.
+
+It writes the key into the language XML next to the closest existing keys, inserts it into the database when that language is installed, and recompiles the language cache. An existing translation is left unchanged.
+
+```bash
+docker compose exec -T -w /opt/una php php tools/add_lang_key.php <<'EOF'
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"language":"en","key":"_sys_example","translation":"Hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
+EOF
+```
+
+A successful `add` result has `content` (a short status) and `structuredContent` (`file`, `xml` of `added` or `already_present`, `keptExisting`, `languageInstalled`, `keyCreated`, `translationStatus` of `added`, `already_present`, or `skipped`, `keyId`, `category`, `recompiled`). A failure comes back as `isError: true`. When the language is not installed, the XML is still written and `languageInstalled` is false.
+
+## Database
+
+`db.php` queries the installed UNA database. It reads the host, name, user, and password from `inc/header.inc.php` and connects directly. Run it inside the `php` service so that host (`mysql`) resolves. A `tools/call` of the `query` tool runs the statement on the live database for this instance.
+
+```bash
+docker compose exec -T -w /opt/una php php tools/db.php <<'EOF'
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query","arguments":{"sql":"SELECT id, name FROM sys_modules LIMIT 5"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
+EOF
+```
+
+A successful `query` result has `content` (tab-separated text; the word `NULL` stands for a null) and `structuredContent.results` (column names and rows, JSON `null` for a null). 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 and the error says so. A statement that commits by itself, such as a schema change, stays applied; the error then includes the results of the statements that already committed. A statement that changes rows reports `affected`. A result set contains at most 1000 rows. A truncated set has `truncated` true and the text says it was truncated. A SQL failure comes back as `isError: true`.

+ 919 - 0
tools/add_lang_key.php

@@ -0,0 +1,919 @@
+<?php
+/**
+ * UNA language-key MCP server (stdio, protocol 2026-07-28).
+ *
+ * Adds a missing English or Russian string to the language XML and, when
+ * that language is installed, to the database. An existing translation is
+ * left unchanged. The language cache is recompiled after a new string.
+ *
+ * Messages are one JSON-RPC object per line on stdin. Responses are one
+ * JSON-RPC object per line on stdout. Logs go to stderr.
+ *
+ *   docker compose exec -T -w /opt/una php php tools/add_lang_key.php <<'EOF'
+ *   {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"language":"en","key":"_sys_example","translation":"Hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
+ *   EOF
+ */
+
+if (PHP_SAPI !== 'cli')
+    exit(1);
+
+const MCP_PROTOCOL_VERSION = '2026-07-28';
+const MCP_SERVER_NAME = 'una-lang';
+const MCP_SERVER_VERSION = '1.0.0';
+
+const JSONRPC_PARSE_ERROR = -32700;
+const JSONRPC_INVALID_REQUEST = -32600;
+const JSONRPC_METHOD_NOT_FOUND = -32601;
+const JSONRPC_INVALID_PARAMS = -32602;
+const MCP_UNSUPPORTED_PROTOCOL_VERSION = -32022;
+
+ini_set('display_errors', 'stderr');
+ini_set('log_errors', '1');
+
+if (isset($argv[1])) {
+    fwrite(STDERR, usage());
+    exit($argv[1] === '--help' || $argv[1] === '-h' ? 0 : 2);
+}
+
+if (stream_isatty(STDIN))
+    fwrite(STDERR, "una-lang MCP server (" . MCP_PROTOCOL_VERSION . "), reading JSON-RPC from stdin\n");
+
+while (($sLine = fgets(STDIN)) !== false) {
+    $sLine = rtrim($sLine, "\r\n");
+    if ($sLine === '')
+        continue;
+    handleLine($sLine);
+}
+
+exit(0);
+
+function handleLine(string $sLine): void
+{
+    try {
+        $mixed = json_decode($sLine, false, 512, JSON_THROW_ON_ERROR);
+    }
+    catch (JsonException $oException) {
+        writeError(null, JSONRPC_PARSE_ERROR, 'Parse error');
+        return;
+    }
+
+    if (!is_object($mixed)) {
+        writeError(null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
+        return;
+    }
+
+    $bHasId = property_exists($mixed, 'id');
+    $mId = $bHasId ? $mixed->id : null;
+    $bValidId = is_string($mId) || is_int($mId);
+    if (!$bHasId)
+        return;
+
+    $sMethod = $mixed->method ?? null;
+    if (!$bValidId || ($mixed->jsonrpc ?? null) !== '2.0' || !is_string($sMethod) || $sMethod === '') {
+        writeError($bValidId ? $mId : null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
+        return;
+    }
+
+    if (isset($mixed->params) && !is_object($mixed->params)) {
+        writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params');
+        return;
+    }
+    $oParams = $mixed->params ?? new stdClass();
+
+    $aProtocolError = protocolError($oParams);
+    if ($aProtocolError !== null) {
+        writeError($mId, $aProtocolError[0], $aProtocolError[1], $aProtocolError[2]);
+        return;
+    }
+
+    switch ($sMethod) {
+        case 'server/discover':
+            writeResult($mId, [
+                'supportedVersions' => [MCP_PROTOCOL_VERSION],
+                'capabilities' => [
+                    'tools' => ['listChanged' => false],
+                ],
+                'instructions' => 'Add a missing English or Russian language key with the add tool. The string is written into the language XML and, when that language is installed, into the database. An existing translation is left unchanged.',
+                'ttlMs' => 86400000,
+                'cacheScope' => 'public',
+            ]);
+            return;
+
+        case 'tools/list':
+            if (isset($oParams->cursor) && $oParams->cursor !== '') {
+                if (!is_string($oParams->cursor)) {
+                    writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params: cursor must be a string');
+                    return;
+                }
+                writeResult($mId, [
+                    'tools' => [],
+                    'ttlMs' => 86400000,
+                    'cacheScope' => 'public',
+                ]);
+                return;
+            }
+            writeResult($mId, [
+                'tools' => [addTool()],
+                'ttlMs' => 86400000,
+                'cacheScope' => 'public',
+            ]);
+            return;
+
+        case 'tools/call':
+            $aCall = callTool($oParams);
+            if (isset($aCall['error'])) {
+                writeError($mId, $aCall['error'][0], $aCall['error'][1]);
+                return;
+            }
+            writeResult($mId, $aCall['result']);
+            return;
+
+        default:
+            writeError($mId, JSONRPC_METHOD_NOT_FOUND, 'Method not found');
+    }
+}
+
+/**
+ * @return array{0: int, 1: string, 2: ?array}|null
+ */
+function protocolError(object $oParams): ?array
+{
+    if (!isset($oParams->_meta) || !is_object($oParams->_meta))
+        return [JSONRPC_INVALID_PARAMS, 'Invalid params: _meta is required', null];
+
+    $oMeta = $oParams->_meta;
+    $sVersion = $oMeta->{'io.modelcontextprotocol/protocolVersion'} ?? null;
+    if (!is_string($sVersion) || $sVersion === '')
+        return [JSONRPC_INVALID_PARAMS, 'Invalid params: protocol version is required', null];
+    if ($sVersion !== MCP_PROTOCOL_VERSION) {
+        return [MCP_UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
+            'supported' => [MCP_PROTOCOL_VERSION],
+            'requested' => $sVersion,
+        ]];
+    }
+
+    $mCapabilities = $oMeta->{'io.modelcontextprotocol/clientCapabilities'} ?? null;
+    if (!is_object($mCapabilities))
+        return [JSONRPC_INVALID_PARAMS, 'Invalid params: client capabilities are required', null];
+
+    return null;
+}
+
+function addTool(): array
+{
+    return [
+        'name' => 'add',
+        'title' => 'Add language key',
+        'description' => 'Add a missing English or Russian string. The key is inserted into the language XML beside the closest existing keys, and into the database when that language is installed. An existing translation is left unchanged. Pass module when the key prefix does not name the module (for example _bx_orgs_ belongs to bx_organizations).',
+        'inputSchema' => [
+            'type' => 'object',
+            'properties' => [
+                'language' => [
+                    'type' => 'string',
+                    'enum' => ['en', 'ru'],
+                    'description' => 'Language name: en or ru.',
+                ],
+                'key' => [
+                    'type' => 'string',
+                    'description' => 'Language key, a single line such as _sys_example or _bx_posts_example.',
+                ],
+                'translation' => [
+                    'type' => 'string',
+                    'description' => 'Translation text. Line breaks are kept.',
+                ],
+                'module' => [
+                    'type' => 'string',
+                    'description' => 'Module name to write into when the key prefix cannot identify it.',
+                ],
+            ],
+            'required' => ['language', 'key', 'translation'],
+            'additionalProperties' => false,
+        ],
+        'outputSchema' => [
+            'type' => 'object',
+            'properties' => [
+                'language' => ['type' => 'string'],
+                'key' => ['type' => 'string'],
+                'file' => ['type' => 'string'],
+                'xml' => [
+                    'type' => 'string',
+                    'enum' => ['added', 'already_present'],
+                ],
+                'keptExisting' => ['type' => 'boolean'],
+                'languageInstalled' => ['type' => 'boolean'],
+                'keyCreated' => ['type' => 'boolean'],
+                'translationStatus' => [
+                    'type' => 'string',
+                    'enum' => ['added', 'already_present', 'skipped'],
+                ],
+                'keyId' => ['type' => ['integer', 'null']],
+                'category' => ['type' => 'string'],
+                'recompiled' => ['type' => 'boolean'],
+            ],
+            'required' => [
+                'language',
+                'key',
+                'file',
+                'xml',
+                'keptExisting',
+                'languageInstalled',
+                'keyCreated',
+                'translationStatus',
+                'keyId',
+                'category',
+                'recompiled',
+            ],
+        ],
+        'annotations' => [
+            'title' => 'Add language key',
+            'readOnlyHint' => false,
+            'destructiveHint' => false,
+            'idempotentHint' => true,
+            'openWorldHint' => false,
+        ],
+    ];
+}
+
+/**
+ * @return array{error: array{0: int, 1: string}}|array{result: array}
+ */
+function callTool(object $oParams): array
+{
+    if (!isset($oParams->name) || !is_string($oParams->name) || $oParams->name === '')
+        return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: tool name is required']];
+    if ($oParams->name !== 'add')
+        return ['error' => [JSONRPC_INVALID_PARAMS, 'Unknown tool: ' . $oParams->name]];
+    if (isset($oParams->arguments) && !is_object($oParams->arguments))
+        return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: arguments must be an object']];
+
+    $oArgs = $oParams->arguments ?? new stdClass();
+    $sInvalid = invalidAddArguments($oArgs);
+    if ($sInvalid !== null)
+        return ['result' => toolError($sInvalid)];
+
+    $sModule = (isset($oArgs->module) && is_string($oArgs->module) && $oArgs->module !== '') ? $oArgs->module : null;
+
+    try {
+        $aAdded = addLanguageKey(strtolower($oArgs->language), $oArgs->key, $oArgs->translation, $sModule);
+    }
+    catch (Throwable $oException) {
+        return ['result' => toolError($oException->getMessage())];
+    }
+
+    $bFailed = isset($aAdded['error']);
+    $sText = statusText($aAdded['status'], $bFailed);
+    if ($bFailed)
+        $sText .= "\n" . $aAdded['error'];
+
+    return ['result' => [
+        'content' => [[
+            'type' => 'text',
+            'text' => $sText,
+        ]],
+        'structuredContent' => $aAdded['status'],
+        'isError' => isset($aAdded['error']),
+    ]];
+}
+
+function invalidAddArguments(object $oArgs): ?string
+{
+    $mLang = $oArgs->language ?? null;
+    if (!is_string($mLang) || !in_array(strtolower($mLang), ['en', 'ru'], true))
+        return 'language must be en or ru';
+
+    $mKey = $oArgs->key ?? null;
+    if (!is_string($mKey) || $mKey === '')
+        return 'key must be a non-empty string';
+    if (strpbrk($mKey, "\r\n") !== false)
+        return 'key must be a single line';
+
+    $mTranslation = $oArgs->translation ?? null;
+    if (!is_string($mTranslation) || $mTranslation === '')
+        return 'translation must be a non-empty string';
+    if (function_exists('mb_check_encoding') && !mb_check_encoding($mTranslation, 'UTF-8'))
+        return 'translation is not valid UTF-8';
+
+    if (isset($oArgs->module) && !is_string($oArgs->module))
+        return 'module must be a string';
+
+    return null;
+}
+
+function toolError(string $sMessage): array
+{
+    return [
+        'content' => [[
+            'type' => 'text',
+            'text' => $sMessage,
+        ]],
+        'isError' => true,
+    ];
+}
+
+/**
+ * @param array{language: string, key: string, file: string, xml: string, keptExisting: bool, languageInstalled: bool, keyCreated: bool, translationStatus: string, keyId: int|null, category: string, recompiled: bool} $aStatus
+ */
+function statusText(array $aStatus, bool $bFailed): string
+{
+    $aLines = [];
+    if ($aStatus['xml'] === 'added')
+        $aLines[] = 'xml: added to ' . $aStatus['file'];
+    else {
+        $aLines[] = 'xml: already present in ' . $aStatus['file'];
+        if ($aStatus['keptExisting'])
+            $aLines[] = 'kept the translation already in the language file';
+    }
+
+    if (!$aStatus['languageInstalled']) {
+        if (!$bFailed)
+            $aLines[] = "Language '" . $aStatus['language'] . "' is not installed in the database";
+        return implode("\n", $aLines);
+    }
+
+    if ($aStatus['keyCreated'])
+        $aLines[] = 'db: created key ' . $aStatus['keyId'] . " in '" . $aStatus['category'] . "'";
+
+    if ($aStatus['translationStatus'] === 'already_present')
+        $aLines[] = 'db: already present for ' . $aStatus['language'];
+    elseif ($aStatus['translationStatus'] === 'added' && $aStatus['recompiled'])
+        $aLines[] = 'db: added translation for ' . $aStatus['language'] . ' and recompiled';
+    elseif ($aStatus['translationStatus'] === 'added')
+        $aLines[] = 'db: added translation for ' . $aStatus['language'];
+
+    return implode("\n", $aLines);
+}
+
+/**
+ * @return array{status: array, error?: string}
+ */
+function addLanguageKey(string $sLang, string $sKey, string $sTranslation, ?string $sForcedModule): array
+{
+    $sRoot = dirname(__DIR__) . '/';
+    $aModules = loadModules($sRoot);
+
+    if ($sForcedModule !== null) {
+        if (!isset($aModules[$sForcedModule]))
+            throw new RuntimeException("Unknown module '$sForcedModule'");
+        $aModule = $aModules[$sForcedModule];
+    }
+    else {
+        $aModule = findModuleByExistingKey($sRoot, $sKey, $aModules);
+        if (!$aModule)
+            $aModule = inferModule($sKey, $aModules);
+    }
+
+    $sFile = resolveLangFile($sRoot, $sLang, $aModule, loadLanguageModules($sRoot));
+    $sRel = ltrim(str_replace('\\', '/', substr($sFile, strlen($sRoot))), '/');
+
+    $sExisting = is_file($sFile) ? readLangString($sFile, $sKey) : null;
+    if ($sExisting !== null) {
+        $sXml = 'already_present';
+        $bKept = $sExisting !== $sTranslation;
+        $sStore = $sExisting;
+    }
+    else {
+        if (!writeLangString($sFile, $sLang, $sKey, $sTranslation))
+            throw new RuntimeException("Failed to write $sRel");
+        $sXml = 'added';
+        $bKept = false;
+        $sStore = $sTranslation;
+    }
+
+    $aStatus = [
+        'language' => $sLang,
+        'key' => $sKey,
+        'file' => $sRel,
+        'xml' => $sXml,
+        'keptExisting' => $bKept,
+        'languageInstalled' => false,
+        'keyCreated' => false,
+        'translationStatus' => 'skipped',
+        'keyId' => null,
+        'category' => $aModule['language_category'],
+        'recompiled' => false,
+    ];
+
+    try {
+        dbAddMissing($sLang, $sKey, $sStore, $aModule['language_category'], $aStatus);
+    }
+    catch (Throwable $oException) {
+        return ['error' => $oException->getMessage(), 'status' => $aStatus];
+    }
+
+    return ['status' => $aStatus];
+}
+
+/**
+ * @param array{languageInstalled: bool, keyCreated: bool, translationStatus: string, keyId: int|null, category: string, recompiled: bool} $aStatus
+ */
+function dbAddMissing(string $sLang, string $sKey, string $sTranslation, string $sCategory, array &$aStatus): void
+{
+    $iBuffers = ob_get_level();
+    ob_start();
+    try {
+        bootUna();
+
+        $oDb = BxDolDb::getInstance();
+        $aLang = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_languages` WHERE `Name` = ? LIMIT 1", $sLang));
+        if (!$aLang) {
+            $aStatus['languageInstalled'] = false;
+            return;
+        }
+        $aStatus['languageInstalled'] = true;
+        $iLangId = (int)$aLang['id'];
+
+        $aKey = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_keys` WHERE `Key` = ? LIMIT 1", $sKey));
+        if (!$aKey) {
+            $iCategoryId = categoryId($oDb, $sCategory);
+            if (!$iCategoryId)
+                throw new RuntimeException("Could not resolve language category '$sCategory'");
+
+            $iInserted = (int)$oDb->query($oDb->prepare(
+                "INSERT INTO `sys_localization_keys` (`IDCategory`, `Key`) VALUES (?, ?)",
+                $iCategoryId,
+                $sKey
+            ));
+            if ($iInserted <= 0)
+                throw new RuntimeException('Could not insert language key');
+
+            $aStatus['keyCreated'] = true;
+            $aStatus['keyId'] = (int)$oDb->lastId();
+        }
+        else {
+            $aStatus['keyId'] = (int)$aKey['id'];
+        }
+
+        $iKeyId = $aStatus['keyId'];
+        $aString = $oDb->getRow($oDb->prepare(
+            "SELECT `IDKey` AS `id` FROM `sys_localization_strings` WHERE `IDKey` = ? AND `IDLanguage` = ? LIMIT 1",
+            $iKeyId,
+            $iLangId
+        ));
+        if ($aString) {
+            $aStatus['translationStatus'] = 'already_present';
+            return;
+        }
+
+        $iInserted = (int)$oDb->query($oDb->prepare(
+            "INSERT INTO `sys_localization_strings` (`IDKey`, `IDLanguage`, `String`) VALUES (?, ?, ?)",
+            $iKeyId,
+            $iLangId,
+            $sTranslation
+        ));
+        if ($iInserted <= 0)
+            throw new RuntimeException('Could not insert translation');
+
+        $aStatus['translationStatus'] = 'added';
+
+        bx_import('BxDolStudioLanguagesUtils');
+        BxDolLanguages::getInstance();
+        if (!BxDolStudioLanguagesUtils::getInstance()->compileLanguage($iLangId, true))
+            throw new RuntimeException('Translation stored, but the language cache was not recompiled');
+
+        $aStatus['recompiled'] = true;
+    }
+    finally {
+        while (ob_get_level() > $iBuffers) {
+            $sLeak = ob_get_clean();
+            if (is_string($sLeak) && $sLeak !== '')
+                fwrite(STDERR, $sLeak);
+        }
+    }
+}
+
+function categoryId(object $oDb, string $sCategory): int
+{
+    $aCategory = $oDb->getRow($oDb->prepare(
+        "SELECT `ID` AS `id` FROM `sys_localization_categories` WHERE `Name` = ? LIMIT 1",
+        $sCategory
+    ));
+    if ($aCategory)
+        return (int)$aCategory['id'];
+
+    $iInserted = (int)$oDb->query($oDb->prepare(
+        "INSERT INTO `sys_localization_categories` (`Name`) VALUES (?)",
+        $sCategory
+    ));
+    if ($iInserted <= 0)
+        return 0;
+
+    return (int)$oDb->lastId();
+}
+
+function bootUna(): void
+{
+    if (defined('BX_DOL'))
+        return;
+
+    $GLOBALS['bx_profiler_disable'] = true;
+    if (!defined('BX_DOL_CRON_EXECUTE'))
+        define('BX_DOL_CRON_EXECUTE', '1');
+
+    $_ENV['UNA_SKIP_REDIRECT'] = '1';
+    $_ENV['UNA_SKIP_MINIMAL_REQUIREMENTS'] = '1';
+    $_ENV['UNA_SKIP_INSTALL_FOLDER_CHECK'] = '1';
+    $_ENV['UNA_DEBUG_VISUAL_PROCESSING'] = false;
+    $_ENV['UNA_DEBUG_EMAIL_REPORT'] = false;
+    $_ENV['UNA_DEBUG_MODE'] = false;
+
+    if (empty($_SERVER['HTTP_HOST'])) {
+        $_SERVER['HTTP_HOST'] = 'hihi.com';
+        $_SERVER['REQUEST_URI'] = '/una/';
+        $_SERVER['SERVER_NAME'] = 'hihi.com';
+        $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+    }
+
+    $sHeader = dirname(__DIR__) . '/inc/header.inc.php';
+    if (!is_readable($sHeader))
+        throw new RuntimeException('UNA is not installed (inc/header.inc.php is missing)');
+
+    require_once $sHeader;
+    require_once BX_DIRECTORY_PATH_INC . 'design.inc.php';
+}
+
+function loadModules(string $sRoot): array
+{
+    $a = [
+        'system' => [
+            'name' => 'system',
+            'home_dir' => 'system/',
+            'language_category' => 'System',
+        ],
+    ];
+
+    foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
+        $s = file_get_contents($sConfig);
+        if ($s === false)
+            continue;
+
+        $sName = configValue($s, 'name');
+        $sHome = configValue($s, 'home_dir');
+        if ($sName === null || $sHome === null || $sName === '')
+            continue;
+
+        $sCategory = configValue($s, 'language_category');
+        if ($sCategory === null || $sCategory === '')
+            $sCategory = $sName;
+
+        $a[$sName] = [
+            'name' => $sName,
+            'home_dir' => rtrim($sHome, '/') . '/',
+            'language_category' => $sCategory,
+        ];
+    }
+
+    return $a;
+}
+
+function loadLanguageModules(string $sRoot): array
+{
+    $a = [
+        'en' => ['home_dir' => 'boonex/english/'],
+        'ru' => ['home_dir' => 'boonex/russian/'],
+    ];
+
+    foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
+        $s = file_get_contents($sConfig);
+        if ($s === false)
+            continue;
+
+        $sUri = configValue($s, 'home_uri');
+        $sHome = configValue($s, 'home_dir');
+        if (($sUri !== 'en' && $sUri !== 'ru') || $sHome === null)
+            continue;
+
+        $a[$sUri] = ['home_dir' => rtrim($sHome, '/') . '/'];
+    }
+
+    return $a;
+}
+
+function configValue(string $sSource, string $sKey): ?string
+{
+    $sPattern = '/[\'"]' . preg_quote($sKey, '/') . '[\'"]\s*=>\s*[\'"]([^\'"]*)[\'"]/';
+    if (!preg_match($sPattern, $sSource, $aMatch))
+        return null;
+    return $aMatch[1];
+}
+
+function findModuleByExistingKey(string $sRoot, string $sKey, array $aModules): ?array
+{
+    $aFound = [];
+    $aFiles = array_merge(
+        glob($sRoot . 'modules/*/*/install/langs/*.xml') ?: [],
+        glob($sRoot . 'modules/*/*/data/langs/*/*.xml') ?: []
+    );
+
+    foreach ($aFiles as $sFile) {
+        if (readLangString($sFile, $sKey) === null)
+            continue;
+
+        $sNorm = str_replace('\\', '/', $sFile);
+        $aModule = null;
+
+        if (preg_match('#/data/langs/([^/]+)/[^/]+\.xml$#', $sNorm, $aMatch) && isset($aModules[$aMatch[1]]))
+            $aModule = $aModules[$aMatch[1]];
+        elseif (preg_match('#/modules/(.+)/install/langs/[^/]+\.xml$#', $sNorm, $aMatch)) {
+            $sHome = $aMatch[1] . '/';
+            foreach ($aModules as $aCandidate) {
+                if ($aCandidate['home_dir'] === $sHome) {
+                    $aModule = $aCandidate;
+                    break;
+                }
+            }
+        }
+
+        if ($aModule)
+            $aFound[$aModule['name']] = $aModule;
+    }
+
+    if (!$aFound)
+        return null;
+
+    if (isset($aFound['system']) && count($aFound) > 1)
+        unset($aFound['system']);
+
+    return reset($aFound);
+}
+
+function inferModule(string $sKey, array $aModules): array
+{
+    $sBare = ltrim($sKey, '_');
+    $aNames = array_keys($aModules);
+    usort($aNames, function ($sA, $sB) {
+        return strlen($sB) - strlen($sA);
+    });
+
+    foreach ($aNames as $sName) {
+        if ($sName === 'system')
+            continue;
+        if ($sBare === $sName || strpos($sBare, $sName . '_') === 0)
+            return $aModules[$sName];
+    }
+
+    return $aModules['system'];
+}
+
+function resolveLangFile(string $sRoot, string $sLang, array $aModule, array $aLangModules): string
+{
+    $sPack = $sRoot . 'modules/' . $aLangModules[$sLang]['home_dir'] . 'data/langs/' . $aModule['name'] . '/' . $sLang . '.xml';
+    if (is_file($sPack))
+        return $sPack;
+
+    if ($aModule['name'] !== 'system') {
+        $sInstall = $sRoot . 'modules/' . $aModule['home_dir'] . 'install/langs/' . $sLang . '.xml';
+        if (is_file($sInstall) || $sLang === 'en')
+            return $sInstall;
+    }
+
+    return $sPack;
+}
+
+function readLangString(string $sPath, string $sKey): ?string
+{
+    $s = @file_get_contents($sPath);
+    if ($s === false)
+        return null;
+
+    $aVariants = array_unique([$sKey, htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8')]);
+    foreach ($aVariants as $sVariant) {
+        $sPattern = '/<string\b[^>]*\bname=(["\'])' . preg_quote($sVariant, '/') . '\\1[^>]*>(.*?)<\/string>/s';
+        if (!preg_match($sPattern, $s, $aMatch))
+            continue;
+
+        $sInner = $aMatch[2];
+        if (preg_match('/^\s*<!\[CDATA\[(.*)\]\]>\s*$/s', $sInner, $aCdata))
+            return str_replace(']]]]><![CDATA[>', ']]>', $aCdata[1]);
+
+        return html_entity_decode($sInner, ENT_QUOTES | ENT_XML1, 'UTF-8');
+    }
+
+    return null;
+}
+
+function writeLangString(string $sFile, string $sLang, string $sKey, string $sTranslation): bool
+{
+    $aMeta = [
+        'en' => ['flag' => 'gb', 'title' => 'English'],
+        'ru' => ['flag' => 'ru', 'title' => 'Russian'],
+    ];
+
+    $sNl = "\n";
+    if (is_file($sFile)) {
+        $sXml = file_get_contents($sFile);
+        if ($sXml === false)
+            return false;
+        if (strpos($sXml, "\r\n") !== false)
+            $sNl = "\r\n";
+    }
+    else {
+        $sDir = dirname($sFile);
+        if (!is_dir($sDir) && !mkdir($sDir, 0775, true))
+            return false;
+
+        $sXml = '<?xml version="1.0" encoding="utf-8"?>' . $sNl
+            . '<resources name="' . $sLang . '" flag="' . $aMeta[$sLang]['flag'] . '" title="' . $aMeta[$sLang]['title'] . '">' . $sNl
+            . '</resources>' . $sNl;
+    }
+
+    $iPos = insertionOffset($sXml, $sKey);
+    if ($iPos === false)
+        return false;
+
+    $sIndent = indentAt($sXml, $iPos);
+    $sLine = $sIndent . '<string name="' . htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8') . '"><![CDATA[' . cdata($sTranslation) . ']]></string>' . $sNl;
+    if ($iPos > 0 && $sXml[$iPos - 1] !== "\n")
+        $sLine = $sNl . $sLine;
+
+    $sXml = substr($sXml, 0, $iPos) . $sLine . substr($sXml, $iPos);
+    return file_put_contents($sFile, $sXml, LOCK_EX) !== false;
+}
+
+/**
+ * Byte offset where $sKey should be inserted: after the closest earlier
+ * sibling, or before </resources> when nothing shares a meaningful prefix.
+ * Sibling groups stay intact, so a new key lands beside a family rather
+ * than between two keys that belong together.
+ *
+ * @return int|false
+ */
+function insertionOffset(string $sXml, string $sKey)
+{
+    $iFallback = strrpos($sXml, '</resources>');
+    if ($iFallback === false)
+        return false;
+
+    $aEntries = langStringEntries($sXml);
+    if (!$aEntries)
+        return $iFallback;
+
+    $iBest = 0;
+    foreach ($aEntries as $aEntry) {
+        $iScore = commonNonEmptySegments($sKey, $aEntry['name']);
+        if ($iScore > $iBest)
+            $iBest = $iScore;
+    }
+    if ($iBest < 2)
+        return $iFallback;
+
+    $aClose = [];
+    foreach ($aEntries as $i => $aEntry) {
+        if (commonNonEmptySegments($sKey, $aEntry['name']) === $iBest)
+            $aClose[$i] = $aEntry;
+    }
+
+    $iPred = null;
+    $sPredName = null;
+    foreach ($aClose as $i => $aEntry) {
+        if (strcmp($aEntry['name'], $sKey) < 0 && ($sPredName === null || strcmp($aEntry['name'], $sPredName) > 0)) {
+            $iPred = $i;
+            $sPredName = $aEntry['name'];
+        }
+    }
+    if ($iPred !== null)
+        return clusterEdge($aEntries, $iPred, $iBest, false);
+
+    $iSucc = null;
+    $sSuccName = null;
+    foreach ($aClose as $i => $aEntry) {
+        if (strcmp($aEntry['name'], $sKey) > 0 && ($sSuccName === null || strcmp($aEntry['name'], $sSuccName) < 0)) {
+            $iSucc = $i;
+            $sSuccName = $aEntry['name'];
+        }
+    }
+    if ($iSucc !== null)
+        return clusterEdge($aEntries, $iSucc, $iBest, true);
+
+    return $iFallback;
+}
+
+function clusterEdge(array $aEntries, int $iAnchor, int $iMinScore, bool $bStart): int
+{
+    $sAnchor = $aEntries[$iAnchor]['name'];
+    $iEdge = $iAnchor;
+
+    if ($bStart) {
+        for ($i = $iAnchor - 1; $i >= 0; $i--) {
+            if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
+                break;
+            $iEdge = $i;
+        }
+        return $aEntries[$iEdge]['start'];
+    }
+
+    $iCount = count($aEntries);
+    for ($i = $iAnchor + 1; $i < $iCount; $i++) {
+        if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
+            break;
+        $iEdge = $i;
+    }
+    return $aEntries[$iEdge]['end'];
+}
+
+function langStringEntries(string $sXml): array
+{
+    $aEntries = [];
+    $sPattern = '/^([ \t]*)<string\b[^>]*\bname=(["\'])(.*?)\2[^>]*>.*?<\/string>[ \t]*(?:\r?\n)?/ms';
+    if (!preg_match_all($sPattern, $sXml, $aMatches, PREG_OFFSET_CAPTURE))
+        return $aEntries;
+
+    foreach ($aMatches[0] as $i => $aFull) {
+        $aEntries[] = [
+            'name' => html_entity_decode($aMatches[3][$i][0], ENT_QUOTES | ENT_XML1, 'UTF-8'),
+            'indent' => $aMatches[1][$i][0],
+            'start' => $aFull[1],
+            'end' => $aFull[1] + strlen($aFull[0]),
+        ];
+    }
+    return $aEntries;
+}
+
+function commonNonEmptySegments(string $sA, string $sB): int
+{
+    $aA = explode('_', $sA);
+    $aB = explode('_', $sB);
+    $n = min(count($aA), count($aB));
+    $iNonEmpty = 0;
+    for ($i = 0; $i < $n; $i++) {
+        if ($aA[$i] !== $aB[$i])
+            break;
+        if ($aA[$i] !== '')
+            $iNonEmpty++;
+    }
+    return $iNonEmpty;
+}
+
+function indentAt(string $sXml, int $iPos): string
+{
+    $aEntries = langStringEntries($sXml);
+    $sIndent = '    ';
+    foreach ($aEntries as $aEntry) {
+        if ($aEntry['start'] <= $iPos)
+            $sIndent = $aEntry['indent'];
+        if ($aEntry['start'] >= $iPos)
+            return $aEntry['indent'] !== '' ? $aEntry['indent'] : $sIndent;
+    }
+    return $sIndent;
+}
+
+function cdata(string $s): string
+{
+    return str_replace(']]>', ']]]]><![CDATA[>', $s);
+}
+
+/**
+ * @param string|int|null $mId
+ * @param array<string, mixed> $aResult
+ */
+function writeResult(string|int|null $mId, array $aResult): void
+{
+    $aResult['resultType'] = 'complete';
+    $aResult['_meta'] = [
+        'io.modelcontextprotocol/serverInfo' => [
+            'name' => MCP_SERVER_NAME,
+            'version' => MCP_SERVER_VERSION,
+        ],
+    ];
+    writeMessage([
+        'jsonrpc' => '2.0',
+        'id' => $mId,
+        'result' => $aResult,
+    ]);
+}
+
+/**
+ * @param string|int|null $mId
+ */
+function writeError(string|int|null $mId, int $iCode, string $sMessage, ?array $aData = null): void
+{
+    $aError = [
+        'code' => $iCode,
+        'message' => $sMessage,
+    ];
+    if ($aData !== null)
+        $aError['data'] = $aData;
+
+    $aMessage = ['jsonrpc' => '2.0'];
+    if ($mId !== null)
+        $aMessage['id'] = $mId;
+    $aMessage['error'] = $aError;
+    writeMessage($aMessage);
+}
+
+function writeMessage(array $aMessage): void
+{
+    $sJson = json_encode($aMessage, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
+    fwrite(STDOUT, $sJson . "\n");
+    fflush(STDOUT);
+}
+
+function usage(): string
+{
+    return "Usage: php tools/add_lang_key.php\n"
+        . "Reads newline-delimited JSON-RPC (MCP 2026-07-28) from stdin and writes responses to stdout.\n"
+        . "Methods: server/discover, tools/list, tools/call (tool name: add; arguments: language, key, translation; optional module).\n"
+        . "\n"
+        . "  docker compose exec -T -w /opt/una php php tools/add_lang_key.php <<'EOF'\n"
+        . "  {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"language\":\"en\",\"key\":\"_sys_example\",\"translation\":\"Hello\"},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n"
+        . "  EOF\n";
+}

+ 834 - 0
tools/db.php

@@ -0,0 +1,834 @@
+<?php
+/**
+ * UNA database MCP server (stdio, protocol 2026-07-28).
+ *
+ * Credentials come from inc/header.inc.php. The rest of UNA is not loaded.
+ * Messages are one JSON-RPC object per line on stdin. Responses are one
+ * JSON-RPC object per line on stdout. Logs go to stderr.
+ *
+ *   docker compose exec -T -w /opt/una php php tools/db.php <<'EOF'
+ *   {"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":{}}}}
+ *   EOF
+ */
+
+if (PHP_SAPI !== 'cli')
+    exit(1);
+
+const MCP_PROTOCOL_VERSION = '2026-07-28';
+const MCP_SERVER_NAME = 'una-db';
+const MCP_SERVER_VERSION = '1.0.0';
+const MAX_RESULT_ROWS = 1000;
+
+const JSONRPC_PARSE_ERROR = -32700;
+const JSONRPC_INVALID_REQUEST = -32600;
+const JSONRPC_METHOD_NOT_FOUND = -32601;
+const JSONRPC_INVALID_PARAMS = -32602;
+const MCP_UNSUPPORTED_PROTOCOL_VERSION = -32022;
+
+if (isset($argv[1])) {
+    fwrite(STDERR, usage());
+    exit($argv[1] === '--help' || $argv[1] === '-h' ? 0 : 2);
+}
+
+if (stream_isatty(STDIN))
+    fwrite(STDERR, "una-db MCP server (" . MCP_PROTOCOL_VERSION . "), reading JSON-RPC from stdin\n");
+
+while (($sLine = fgets(STDIN)) !== false) {
+    $sLine = rtrim($sLine, "\r\n");
+    if ($sLine === '')
+        continue;
+    handleLine($sLine);
+}
+
+exit(0);
+
+function handleLine(string $sLine): void
+{
+    try {
+        $mixed = json_decode($sLine, false, 512, JSON_THROW_ON_ERROR);
+    }
+    catch (JsonException $oException) {
+        writeError(null, JSONRPC_PARSE_ERROR, 'Parse error');
+        return;
+    }
+
+    if (!is_object($mixed)) {
+        writeError(null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
+        return;
+    }
+
+    $bHasId = property_exists($mixed, 'id');
+    $mId = $bHasId ? $mixed->id : null;
+    $bValidId = is_string($mId) || is_int($mId);
+    if (!$bHasId)
+        return;
+
+    $sMethod = $mixed->method ?? null;
+    if (!$bValidId || ($mixed->jsonrpc ?? null) !== '2.0' || !is_string($sMethod) || $sMethod === '') {
+        writeError($bValidId ? $mId : null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
+        return;
+    }
+
+    if (isset($mixed->params) && !is_object($mixed->params)) {
+        writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params');
+        return;
+    }
+    $oParams = $mixed->params ?? new stdClass();
+
+    $aProtocolError = protocolError($oParams);
+    if ($aProtocolError !== null) {
+        writeError($mId, $aProtocolError[0], $aProtocolError[1], $aProtocolError[2]);
+        return;
+    }
+
+    switch ($sMethod) {
+        case 'server/discover':
+            writeResult($mId, [
+                'supportedVersions' => [MCP_PROTOCOL_VERSION],
+                'capabilities' => [
+                    'tools' => ['listChanged' => false],
+                ],
+                'instructions' => 'Run SQL on the installed UNA database with the query tool. Statements execute on the live database for this instance.',
+                'ttlMs' => 86400000,
+                'cacheScope' => 'public',
+            ]);
+            return;
+
+        case 'tools/list':
+            if (isset($oParams->cursor) && $oParams->cursor !== '') {
+                if (!is_string($oParams->cursor)) {
+                    writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params: cursor must be a string');
+                    return;
+                }
+                writeResult($mId, [
+                    'tools' => [],
+                    'ttlMs' => 86400000,
+                    'cacheScope' => 'public',
+                ]);
+                return;
+            }
+            writeResult($mId, [
+                'tools' => [queryTool()],
+                'ttlMs' => 86400000,
+                'cacheScope' => 'public',
+            ]);
+            return;
+
+        case 'tools/call':
+            $aCall = callTool($oParams);
+            if (isset($aCall['error'])) {
+                writeError($mId, $aCall['error'][0], $aCall['error'][1]);
+                return;
+            }
+            writeResult($mId, $aCall['result']);
+            return;
+
+        default:
+            writeError($mId, JSONRPC_METHOD_NOT_FOUND, 'Method not found');
+    }
+}
+
+/**
+ * @return array{0: int, 1: string, 2: ?array}|null
+ */
+function protocolError(object $oParams): ?array
+{
+    if (!isset($oParams->_meta) || !is_object($oParams->_meta))
+        return [JSONRPC_INVALID_PARAMS, 'Invalid params: _meta is required', null];
+
+    $oMeta = $oParams->_meta;
+    $sVersion = $oMeta->{'io.modelcontextprotocol/protocolVersion'} ?? null;
+    if (!is_string($sVersion) || $sVersion === '')
+        return [JSONRPC_INVALID_PARAMS, 'Invalid params: protocol version is required', null];
+    if ($sVersion !== MCP_PROTOCOL_VERSION) {
+        return [MCP_UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
+            'supported' => [MCP_PROTOCOL_VERSION],
+            'requested' => $sVersion,
+        ]];
+    }
+
+    $mCapabilities = $oMeta->{'io.modelcontextprotocol/clientCapabilities'} ?? null;
+    if (!is_object($mCapabilities))
+        return [JSONRPC_INVALID_PARAMS, 'Invalid params: client capabilities are required', null];
+
+    return null;
+}
+
+function queryTool(): array
+{
+    return [
+        'name' => 'query',
+        'title' => 'Query UNA database',
+        '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.',
+        'inputSchema' => [
+            'type' => 'object',
+            'properties' => [
+                'sql' => [
+                    'type' => 'string',
+                    'description' => 'SQL to execute.',
+                ],
+            ],
+            'required' => ['sql'],
+            'additionalProperties' => false,
+        ],
+        'outputSchema' => [
+            'type' => 'object',
+            'properties' => [
+                'results' => [
+                    'type' => 'array',
+                    'items' => [
+                        'type' => 'object',
+                        'properties' => [
+                            'columns' => [
+                                'type' => 'array',
+                                'items' => ['type' => 'string'],
+                            ],
+                            'rows' => [
+                                'type' => 'array',
+                                'items' => [
+                                    'type' => 'array',
+                                    'items' => ['type' => ['string', 'null']],
+                                ],
+                            ],
+                            'affected' => ['type' => 'integer'],
+                            'truncated' => ['type' => 'boolean'],
+                        ],
+                    ],
+                ],
+            ],
+            'required' => ['results'],
+        ],
+        'annotations' => [
+            'title' => 'Query UNA database',
+            'readOnlyHint' => false,
+            'destructiveHint' => true,
+            'idempotentHint' => false,
+            'openWorldHint' => false,
+        ],
+    ];
+}
+
+/**
+ * @return array{error: array{0: int, 1: string}}|array{result: array}
+ */
+function callTool(object $oParams): array
+{
+    if (!isset($oParams->name) || !is_string($oParams->name) || $oParams->name === '')
+        return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: tool name is required']];
+    if ($oParams->name !== 'query')
+        return ['error' => [JSONRPC_INVALID_PARAMS, 'Unknown tool: ' . $oParams->name]];
+    if (isset($oParams->arguments) && !is_object($oParams->arguments))
+        return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: arguments must be an object']];
+
+    $mSql = isset($oParams->arguments) ? ($oParams->arguments->sql ?? null) : null;
+    if (!is_string($mSql) || trim($mSql) === '') {
+        return ['result' => toolError('sql must be a non-empty string')];
+    }
+
+    try {
+        $aResults = runSql($mSql);
+    }
+    catch (DbCallException $oException) {
+        return ['result' => toolError($oException->getMessage(), $oException->aResults)];
+    }
+    catch (RuntimeException $oException) {
+        return ['result' => toolError($oException->getMessage())];
+    }
+
+    return ['result' => [
+        'content' => [[
+            'type' => 'text',
+            'text' => formatResults($aResults),
+        ]],
+        'structuredContent' => ['results' => $aResults],
+        'isError' => false,
+    ]];
+}
+
+function toolError(string $sMessage, array $aResults = []): array
+{
+    if ($aResults !== [])
+        $sMessage .= "\n\n" . formatResults($aResults);
+
+    $aError = [
+        'content' => [[
+            'type' => 'text',
+            'text' => $sMessage,
+        ]],
+        'isError' => true,
+    ];
+    if ($aResults !== [])
+        $aError['structuredContent'] = ['results' => $aResults];
+    return $aError;
+}
+
+class DbCallException extends RuntimeException
+{
+    /** @var list<array<string, mixed>> */
+    public array $aResults;
+
+    /**
+     * @param list<array<string, mixed>> $aResults
+     */
+    public function __construct(string $sMessage, array $aResults)
+    {
+        parent::__construct($sMessage);
+        $this->aResults = $aResults;
+    }
+}
+
+/**
+ * @return list<array{columns?: list<string>, rows?: list<list<string|null>>, affected?: int, truncated?: bool}>
+ */
+function runSql(string $sSql): array
+{
+    $aStatements = splitSqlStatements($sSql);
+    if ($aStatements === [])
+        throw new RuntimeException('sql must contain a statement');
+
+    $oDb = dbConnect();
+    try {
+        if (dbInTransaction($oDb))
+            $oDb->rollback();
+    }
+    catch (RuntimeException $oException) {
+        dbDisconnect();
+        $oDb = dbConnect();
+    }
+    dbBegin($oDb);
+    $aPending = [];
+    $aCommitted = [];
+    try {
+        foreach ($aStatements as $sStatement) {
+            $bRestart = statementRestartsTransaction($sStatement);
+            $aStatementResults = runStatement($oDb, $sStatement);
+            if ($bRestart) {
+                $aCommitted = array_merge($aCommitted, $aPending);
+                $aPending = [];
+            }
+            if (!dbInTransaction($oDb)) {
+                if (!$bRestart && statementDiscardsPending($sStatement))
+                    $aPending = [];
+                else
+                    $aCommitted = array_merge($aCommitted, $aPending, $aStatementResults);
+                $aPending = [];
+                dbBegin($oDb);
+            }
+            else {
+                foreach ($aStatementResults as $aResult)
+                    $aPending[] = $aResult;
+            }
+        }
+        if (dbInTransaction($oDb))
+            dbCommit($oDb);
+    }
+    catch (RuntimeException $oException) {
+        $bOpen = false;
+        try {
+            $bOpen = dbInTransaction($oDb);
+        }
+        catch (RuntimeException $oIgnored) {
+            dbDisconnect();
+            $aMaybe = array_merge($aCommitted, $aPending);
+            if ($aMaybe !== []) {
+                throw new DbCallException(
+                    $oException->getMessage() . "\nThe connection closed before the outcome of earlier statements could be confirmed.",
+                    $aMaybe
+                );
+            }
+            throw $oException;
+        }
+
+        if ($bOpen) {
+            try {
+                $oDb->rollback();
+            }
+            catch (mysqli_sql_exception $oRollback) {
+                dbDisconnect();
+            }
+            if ($aCommitted !== []) {
+                throw new DbCallException(
+                    $oException->getMessage() . "\nEarlier statements that already committed are listed below. The rest of this call was rolled back.",
+                    $aCommitted
+                );
+            }
+            if ($aPending !== [])
+                throw new RuntimeException($oException->getMessage() . "\nEarlier statements in this call were rolled back.");
+            throw $oException;
+        }
+
+        $aCommitted = array_merge($aCommitted, $aPending);
+        if ($aCommitted !== []) {
+            throw new DbCallException(
+                $oException->getMessage() . "\nEarlier statements already committed on the live database.",
+                $aCommitted
+            );
+        }
+        throw $oException;
+    }
+
+    return array_merge($aCommitted, $aPending);
+}
+
+/**
+ * @return list<array{columns?: list<string>, rows?: list<list<string|null>>, affected?: int, truncated?: bool}>
+ */
+function runStatement(mysqli $oDb, string $sSql): array
+{
+    try {
+        $oDb->real_query($sSql);
+    }
+    catch (mysqli_sql_exception $oException) {
+        throw new RuntimeException($oException->getMessage());
+    }
+
+    $aResults = [];
+    do {
+        $oResult = $oDb->use_result();
+        if ($oResult instanceof mysqli_result)
+            $aResults[] = fetchBounded($oDb, $oResult);
+        elseif ($oDb->errno)
+            throw new RuntimeException($oDb->error !== '' ? $oDb->error : 'Query failed');
+        elseif ($oDb->field_count === 0)
+            $aResults[] = ['affected' => $oDb->affected_rows];
+    } while ($oDb->more_results() && nextDbResult($oDb));
+
+    if ($oDb->errno)
+        throw new RuntimeException($oDb->error !== '' ? $oDb->error : 'Query failed');
+
+    return $aResults;
+}
+
+/**
+ * @return array{columns: list<string>, rows: list<list<string|null>>, truncated?: bool}
+ */
+function fetchBounded(mysqli $oDb, mysqli_result $oResult): array
+{
+    $aColumns = [];
+    foreach ($oResult->fetch_fields() as $oField)
+        $aColumns[] = $oField->name;
+
+    $aRows = [];
+    $bTruncated = false;
+    while ($aRow = $oResult->fetch_row()) {
+        if (count($aRows) >= MAX_RESULT_ROWS) {
+            $bTruncated = true;
+            break;
+        }
+        $aCells = [];
+        foreach ($aRow as $mValue)
+            $aCells[] = $mValue === null ? null : (string)$mValue;
+        $aRows[] = $aCells;
+    }
+
+    if ($bTruncated)
+        releaseResult($oDb, $oResult);
+    else
+        $oResult->free();
+
+    $aResult = ['columns' => $aColumns, 'rows' => $aRows];
+    if ($bTruncated)
+        $aResult['truncated'] = true;
+    return $aResult;
+}
+
+function releaseResult(mysqli $oDb, mysqli_result $oResult): void
+{
+    try {
+        $oKiller = dbOpen();
+        try {
+            $oKiller->query('KILL QUERY ' . (int)$oDb->thread_id);
+        }
+        catch (mysqli_sql_exception $oException) {
+        }
+        $oKiller->close();
+    }
+    catch (RuntimeException $oException) {
+    }
+
+    try {
+        $oResult->free();
+    }
+    catch (mysqli_sql_exception $oException) {
+        $bAlive = false;
+        try {
+            $bAlive = $oDb->ping();
+        }
+        catch (mysqli_sql_exception $oPing) {
+            $bAlive = false;
+        }
+        if (!$bAlive) {
+            dbDisconnect();
+            throw new RuntimeException($oException->getMessage());
+        }
+    }
+}
+
+/**
+ * @return list<string>
+ */
+function splitSqlStatements(string $sSql): array
+{
+    $aChars = mb_str_split($sSql);
+    $iLen = count($aChars);
+    $aStatements = [];
+    $sCurrent = '';
+    $sMode = '';
+    $sQuote = '';
+
+    for ($i = 0; $i < $iLen; $i++) {
+        $sChar = $aChars[$i];
+        $sNext = $i + 1 < $iLen ? $aChars[$i + 1] : '';
+
+        if ($sMode === 'line') {
+            $sCurrent .= $sChar;
+            if ($sChar === "\n" || $sChar === "\r")
+                $sMode = '';
+            continue;
+        }
+        if ($sMode === 'block') {
+            $sCurrent .= $sChar;
+            if ($sChar === '*' && $sNext === '/') {
+                $sCurrent .= $sNext;
+                $i++;
+                $sMode = '';
+            }
+            continue;
+        }
+        if ($sMode === 'quote' || $sMode === 'ident') {
+            $sCurrent .= $sChar;
+            if ($sMode === 'quote' && $sChar === '\\' && $sNext !== '') {
+                $sCurrent .= $sNext;
+                $i++;
+                continue;
+            }
+            if ($sChar === $sQuote) {
+                if ($sNext === $sQuote) {
+                    $sCurrent .= $sNext;
+                    $i++;
+                    continue;
+                }
+                $sMode = '';
+            }
+            continue;
+        }
+
+        if ($sChar === "'" || $sChar === '"') {
+            $sMode = 'quote';
+            $sQuote = $sChar;
+            $sCurrent .= $sChar;
+            continue;
+        }
+        if ($sChar === '`') {
+            $sMode = 'ident';
+            $sQuote = '`';
+            $sCurrent .= $sChar;
+            continue;
+        }
+        if ($sChar === '#') {
+            $sMode = 'line';
+            $sCurrent .= $sChar;
+            continue;
+        }
+        if ($sChar === '-' && $sNext === '-') {
+            $sThird = $i + 2 < $iLen ? $aChars[$i + 2] : '';
+            if ($sThird === '' || preg_match('/\s/u', $sThird) === 1) {
+                $sMode = 'line';
+                $sCurrent .= $sChar;
+                continue;
+            }
+        }
+        if ($sChar === '/' && $sNext === '*') {
+            $sMode = 'block';
+            $sCurrent .= $sChar;
+            continue;
+        }
+        if ($sChar === ';') {
+            if (sqlWithoutLeadingComments($sCurrent) !== '')
+                $aStatements[] = $sCurrent;
+            $sCurrent = '';
+            continue;
+        }
+        $sCurrent .= $sChar;
+    }
+
+    if (sqlWithoutLeadingComments($sCurrent) !== '')
+        $aStatements[] = $sCurrent;
+    return $aStatements;
+}
+
+function sqlWithoutLeadingComments(string $sSql): string
+{
+    $s = $sSql;
+    do {
+        $sNext = preg_replace(
+            '/\A(?:\s+|\/\*[\s\S]*?\*\/|#.*(?:\n|$)|--[ \t].*(?:\n|$)|--(?:\r\n|\n|\r|$))/u',
+            '',
+            $s,
+            1,
+            $iCount
+        );
+        if (!is_string($sNext))
+            return trim($s);
+        $s = $sNext;
+    } while ($iCount > 0);
+
+    return $s;
+}
+
+function statementRestartsTransaction(string $sSql): bool
+{
+    $s = sqlWithoutLeadingComments($sSql);
+    if (preg_match('/^BEGIN\b(?!\s+NOT\b)/i', $s) === 1)
+        return true;
+    return preg_match('/^START\s+TRANSACTION\b/i', $s) === 1;
+}
+
+function statementDiscardsPending(string $sSql): bool
+{
+    $s = sqlWithoutLeadingComments($sSql);
+    return preg_match('/^ROLLBACK\b(?!\s+TO\b)/i', $s) === 1;
+}
+
+function dbBegin(mysqli $oDb): void
+{
+    try {
+        $oDb->begin_transaction();
+    }
+    catch (mysqli_sql_exception $oException) {
+        throw new RuntimeException($oException->getMessage());
+    }
+}
+
+function dbCommit(mysqli $oDb): void
+{
+    try {
+        $oDb->commit();
+    }
+    catch (mysqli_sql_exception $oException) {
+        try {
+            $oDb->rollback();
+        }
+        catch (mysqli_sql_exception $oRollback) {
+            dbDisconnect();
+        }
+        throw new RuntimeException($oException->getMessage());
+    }
+}
+
+function dbInTransaction(mysqli $oDb): bool
+{
+    try {
+        $oResult = $oDb->query('SELECT @@SESSION.in_transaction');
+    }
+    catch (mysqli_sql_exception $oException) {
+        throw new RuntimeException($oException->getMessage());
+    }
+    $aRow = $oResult->fetch_row();
+    $oResult->free();
+    return isset($aRow[0]) && $aRow[0] === '1';
+}
+
+function nextDbResult(mysqli $oDb): bool
+{
+    try {
+        return $oDb->next_result();
+    }
+    catch (mysqli_sql_exception $oException) {
+        throw new RuntimeException($oException->getMessage());
+    }
+}
+
+/**
+ * @param list<array{columns?: list<string>, rows?: list<list<string|null>>, affected?: int}> $aResults
+ */
+function formatResults(array $aResults): string
+{
+    $aBlocks = [];
+    foreach ($aResults as $aResult) {
+        if (isset($aResult['affected'])) {
+            $aBlocks[] = "affected\t" . $aResult['affected'];
+            continue;
+        }
+        $aLines = [implode("\t", $aResult['columns'])];
+        foreach ($aResult['rows'] as $aRow) {
+            $aCells = [];
+            foreach ($aRow as $mValue) {
+                if ($mValue === null)
+                    $aCells[] = 'NULL';
+                else
+                    $aCells[] = str_replace(["\t", "\r", "\n"], ['\\t', '\\r', '\\n'], $mValue);
+            }
+            $aLines[] = implode("\t", $aCells);
+        }
+        if (!empty($aResult['truncated']))
+            $aLines[] = '(truncated to ' . count($aResult['rows']) . ' rows)';
+        $aBlocks[] = implode("\n", $aLines);
+    }
+    return implode("\n\n", $aBlocks);
+}
+
+function &dbStoredLink(): ?mysqli
+{
+    static $oDb = null;
+    return $oDb;
+}
+
+function dbDisconnect(): void
+{
+    $oDb = &dbStoredLink();
+    if ($oDb instanceof mysqli) {
+        try {
+            $oDb->close();
+        }
+        catch (mysqli_sql_exception $oException) {
+        }
+    }
+    $oDb = null;
+}
+
+function dbConnect(): mysqli
+{
+    $oDb = &dbStoredLink();
+    if ($oDb instanceof mysqli) {
+        try {
+            if ($oDb->ping())
+                return $oDb;
+        }
+        catch (mysqli_sql_exception $oException) {
+            $oDb = null;
+        }
+    }
+
+    $oDb = dbOpen();
+    return $oDb;
+}
+
+function dbOpen(): mysqli
+{
+    $sHeaderPath = dirname(__DIR__) . '/inc/header.inc.php';
+    if (!is_readable($sHeaderPath))
+        throw new RuntimeException('UNA is not installed (inc/header.inc.php is missing)');
+
+    $aDb = dbCredentials(file_get_contents($sHeaderPath));
+    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
+    $oLink = mysqli_init();
+    $iPort = ($aDb['port'] === '' || $aDb['port'] === '0') ? 3306 : (int)$aDb['port'];
+    try {
+        $oLink->real_connect(
+            $aDb['host'],
+            $aDb['user'],
+            $aDb['pass'],
+            $aDb['name'],
+            $iPort,
+            $aDb['sock'] !== '' ? $aDb['sock'] : null
+        );
+        $oLink->set_charset('utf8mb4');
+        $oLink->query("SET NAMES utf8mb4 COLLATE " . $aDb['collate']);
+        $oLink->query("SET sql_mode = ''");
+    }
+    catch (mysqli_sql_exception $oException) {
+        throw new RuntimeException('Database connect failed: ' . $oException->getMessage());
+    }
+
+    return $oLink;
+}
+
+/**
+ * @return array{host: string, sock: string, port: string, user: string, pass: string, name: string, collate: string}
+ */
+function dbCredentials(string $sHeader): array
+{
+    $aMap = [
+        'host' => ['BX_DATABASE_HOST', 'UNA_DB_HOST'],
+        'sock' => ['BX_DATABASE_SOCK', 'UNA_DB_SOCK'],
+        'port' => ['BX_DATABASE_PORT', 'UNA_DB_PORT'],
+        'user' => ['BX_DATABASE_USER', 'UNA_DB_USER'],
+        'pass' => ['BX_DATABASE_PASS', 'UNA_DB_PWD'],
+        'name' => ['BX_DATABASE_NAME', 'UNA_DB_NAME'],
+    ];
+
+    $aDb = [];
+    foreach ($aMap as $sKey => $aPair) {
+        [$sConst, $sEnv] = $aPair;
+        $sFromEnv = getenv($sEnv);
+        if ($sFromEnv !== false) {
+            $aDb[$sKey] = $sFromEnv;
+            continue;
+        }
+
+        $sPattern = "/define\\(\\s*'" . $sConst . "'\\s*,\\s*\\\$_ENV\\['" . preg_quote($sEnv, '/') . "'\\]\\s*\\?\\?\\s*'((?:\\\\'|\\\\\\\\|[^'])*)'\\s*\\)/";
+        if (!preg_match($sPattern, $sHeader, $aMatch))
+            throw new RuntimeException("Cannot read $sConst from inc/header.inc.php");
+        $aDb[$sKey] = str_replace(["\\'", "\\\\"], ["'", "\\"], $aMatch[1]);
+    }
+
+    foreach (['host', 'user', 'name'] as $sKey) {
+        if ($aDb[$sKey] === '' || preg_match('/%[A-Z0-9_]+%/', $aDb[$sKey]))
+            throw new RuntimeException('UNA is not installed (database settings in inc/header.inc.php are empty)');
+    }
+
+    $sCollate = getenv('UNA_DATABASE_COLLATE');
+    $aDb['collate'] = ($sCollate !== false && $sCollate !== '') ? $sCollate : 'utf8mb4_unicode_ci';
+    if (!preg_match('/^[A-Za-z0-9_]+$/', $aDb['collate']))
+        throw new RuntimeException('Invalid database collation');
+
+    return $aDb;
+}
+
+/**
+ * @param string|int|null $mId
+ * @param array<string, mixed> $aResult
+ */
+function writeResult(string|int|null $mId, array $aResult): void
+{
+    $aResult['resultType'] = 'complete';
+    $aResult['_meta'] = [
+        'io.modelcontextprotocol/serverInfo' => [
+            'name' => MCP_SERVER_NAME,
+            'version' => MCP_SERVER_VERSION,
+        ],
+    ];
+    writeMessage([
+        'jsonrpc' => '2.0',
+        'id' => $mId,
+        'result' => $aResult,
+    ]);
+}
+
+/**
+ * @param string|int|null $mId
+ */
+function writeError(string|int|null $mId, int $iCode, string $sMessage, ?array $aData = null): void
+{
+    $aError = [
+        'code' => $iCode,
+        'message' => $sMessage,
+    ];
+    if ($aData !== null)
+        $aError['data'] = $aData;
+
+    $aMessage = ['jsonrpc' => '2.0'];
+    if ($mId !== null)
+        $aMessage['id'] = $mId;
+    $aMessage['error'] = $aError;
+    writeMessage($aMessage);
+}
+
+function writeMessage(array $aMessage): void
+{
+    $sJson = json_encode($aMessage, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
+    fwrite(STDOUT, $sJson . "\n");
+    fflush(STDOUT);
+}
+
+function usage(): string
+{
+    return "Usage: php tools/db.php\n"
+        . "Reads newline-delimited JSON-RPC (MCP 2026-07-28) from stdin and writes responses to stdout.\n"
+        . "Methods: server/discover, tools/list, tools/call (tool name: query, argument: sql).\n"
+        . "\n"
+        . "  docker compose exec -T -w /opt/una php php tools/db.php <<'EOF'\n"
+        . "  {\"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"
+        . "  EOF\n";
+}