bsl-context
by Regsorm
README.md
# bsl-context
[Русский](README_RU.md) | **English**
<a href="https://infostart.ru/1c/articles/2698363/" title="Published on Infostart">
<img src="https://infostart.ru/bitrix/templates/sandbox_empty/assets/tpl/abo/img/logo.svg" alt="Infostart" height="32">
</a>
Published on Infostart: [bsl-context — проверка ИИ-кода 1С на соответствие API платформы](https://infostart.ru/1c/articles/2698363/)
An MCP server providing **1C:Enterprise 8.3** platform context: types, methods,
properties, constructors, system enumeration values — plus static validation of
BSL expressions against a real platform index.
The data source is the platform's syntax assistant (`shcntx_ru.hbk`), parsed by a
custom reader (running 1C is not required).
## Why
Language models and linters handle BSL syntax well but are "blind" to referential
correctness against the platform: whether a system enumeration value exists,
whether a platform type has a given method, whether a global function's argument
count fits its overloads. `bsl-context` covers exactly that layer — it checks code
against the actual API of a specific platform version.
## Features
**Reference tools** — search and details for platform types, methods, properties,
constructors, and enumeration values.
**Module/fragment validation** (`validate_module`) — accepts either a whole
module or an arbitrary fragment; via `tree-sitter-bsl` the server
extracts `Процедура`/`Функция` declarations from the submitted text and does
not treat their calls as typos of platform methods (for a fragment this set is
simply empty); it also validates compiler/extension directive names. Returns
findings with line, column, kind, and confidence, plus a `tree_parsed` flag.
`tree_parsed: false` means the text could not be parsed into a tree (binary
file, language setup failure, parsing deadline) — tree-based checks did not run,
so an empty finding list does NOT mean "no issues". Text-based checks
(declarations, module structure, directives, query rules) always run.
The second flag is `symbols_available`. `false` means configuration names were not
available during the check (the name source never came up, or died mid-flight), so only
platform-level rules were applied; the reason is in `degraded_reason`. An answer without
these fields rests on the full set of names.
Pass `module_path` whenever the module path is known. Without it the validator
cannot tell an object module from an arbitrary fragment and assumes there is no
implicit object context: calls on attributes and tabular sections
(`Товары.Очистить()`) then produce an `unknown_common_module` finding.
The module source is exactly one of two: `source` (text) or `path` (a `.bsl` file
inside the configuration dump root — the `root` field of the name source). In the
latter case the server reads the file itself, so a module of hundreds of kilobytes
does not have to travel through the model context: `module_path` is derived from the
path automatically, and the response gains `source_path`, `source_module_path`,
`source_bytes`, and `source_modified`, so you can see which version of the file was
checked. A path is resolved only inside the dump root (symbolic links and junctions
are resolved before the check), and only `.bsl` files are read. A string that looks
like a file path in `source` is rejected with an explanation: previously it was
parsed as BSL and returned `valid: true` without a single check.
| Finding kind | confidence | Meaning |
|--------------|-----------|---------|
| `unknown_enum_value` | high | System enumeration value does not exist. Open collections (`ЦветаСтиля`, `БиблиотекаКартинок` — the configuration adds its own values) are not checked |
| `wrong_argument_count` | high | Global function argument count outside its overloads |
| `unknown_type_member` | low | Platform type has no such method/property |
| `unknown_new_type` | low | `Новый TypeX` constructor unknown to the platform |
| `unknown_global_method` | high / low | Unknown global call similar to a platform method (fuzzy: strong match → high, weak → low) |
| `undeclared_method` | high | Call is not declared in the submitted module and unknown to the platform (whole-module check only; suppressed in extension modules) |
| `unknown_directive` | high / low | Directive name (`&НаСервере`, `&Перед`, …) not in the whitelist |
| `shadowed_context_name` | high | Variable name is taken by a read-only context property: the assignment fails at runtime. The form-member rule needs `module_path` |
| `unknown_common_module` | high | `ModuleName.Method(...)` — no common module with that name exists in the configuration. Requires an external name source |
| `unknown_metadata_object` | high | `Справочники.Name`, `Документы.Name`, … — no object with that name exists in the collection. Requires an external name source |
| `unknown_global_property` | high / low | The head of an `Name.Member` access is neither declared in the module nor a global context property, yet it is spelled close to a real one: `Справочник.Номенклатура` instead of `Справочники.Номенклатура` (the query language names a table in the singular, the code names the manager in the plural). A head that resembles nothing is passed over silently — otherwise every common-module call would be flagged. Needs no external name source |
| `unknown_manager_method` | high / low | `Справочники.Name.Method(...)` — the object manager has no such method, and the name is close to a real help method (a typo like `НайтиПоРеквизитам` → `НайтиПоРеквизиту`). A method declared in the manager module is left alone. Requires an external name source |
| `reserved_procedure_name` | high | The procedure name is a language keyword (`Выполнить`) or a name the platform rejects (`Найти`). The module does not compile: "Ожидается имя процедуры" |
| `duplicate_declaration` | high | A procedure or function with this name is already declared in the module |
| `unbalanced_module_block` | high | An extra or missing `КонецПроцедуры`/`КонецФункции`. An extra block end is read by the platform as the end of the module, so everything below is lost |
| `temp_table_without_index` | high | A temporary table takes part in a join but has no `ИНДЕКСИРОВАТЬ ПО` |
| `or_in_join_condition` | high | `ИЛИ` splits a join condition, so no index can be used |
| `join_with_subquery` | low | Join with a subquery instead of an indexed temporary table |
| `physical_register_table` | low | Reading a balance register's movement table instead of its virtual table. Requires an external name source |
| `virtual_table_without_filter` | low | A virtual table is called without a filter on its dimensions. Requires an external name source |
| `join_on_unindexed_field` | low | The joined table has neither a standard index nor «Индексировать» on the join field. Requires an external name source |
high-confidence findings have a false-positive rate near zero; low-confidence ones
depend on the accuracy of type inference and the completeness of the `hbk`.
### Module structure
Three of the findings above (`reserved_procedure_name`, `duplicate_declaration`,
`unbalanced_module_block`) catch a module the platform refuses to compile at all
— previously such a module passed validation as sound and the error surfaced only
when the processing was opened. They read the module as text rather than through
the syntax tree: a broken module is exactly what `tree-sitter-bsl` cannot parse,
and declarations around an `ERROR` node are lost. All three are `high`, so they
belong to the `strict` profile and work as a build gate.
```json
{"module": "Процедура Найти()\nКонецПроцедуры\n", "level": 3, "profile": "strict"}
```
### Query optimality
The last six findings come from parsing the 1C query language inside string
literals. A query whose text is built by concatenation with variables, or one the
parser could not read, is not analysed at all — an incomplete parse must not
produce findings. On a typical 1C configuration (14905 modules) 97.3% of the 23260
queries found in the code are parsed.
### Validation levels
Analysis depth is set via the `level` parameter (or `default_validation_level` in
the config), clamped to `[1..=3]`:
- **1** — references with an explicit type name in the source (`Новый TypeX`,
`TypeY.ValueZ`, global function argument counts). Low noise, safe default.
- **2** — additionally, local type inference within a procedure:
`X = Новый TypeX`, `X = TypeY.ValueZ`, the `// @type TypeX` annotation.
- **3** — additionally, return-type tracking: a variable's type from the return
type of a method/property, including chains like `Query.Execute().Select()`.
The higher the level, the more findings — and the more potential false positives.
### What the validator cannot know
The validator sees the text of a SINGLE module plus the platform context. About
the configuration it knows exactly what the external name source tells it (see
"External configuration name source"): it does know the set of objects (hence
`unknown_common_module`, `unknown_metadata_object` and the query optimality
rules). What no source knows is the VISIBILITY RULES for application procedures:
whether a procedure is exported, whether its module is reachable from here,
whether the context matches (server/client).
So an `undeclared_method` finding has three different fates when the name comes
from outside the module:
| Case | What the name source does |
|------|---------------------------|
| An export method of a GLOBAL common module (`Глобальный = Истина`) — called without a prefix from anywhere | the finding is **dropped entirely** |
| An export method of the owner object module of an external data processor — visible to an ordinary form module without a prefix (requires `module_path`) | the finding is **dropped entirely** |
| The name is declared somewhere in the configuration, but whether it is visible from here is unknown | the finding **stays with `low` confidence**, i.e. it is hidden in the `strict` profile |
Extension modules are a case of their own: they call procedures of the module
they extend, are recognized by the `&Перед`, `&После`, `&Вместо`,
`&ИзменениеИКонтроль` directives, and strict checking is disabled for them
entirely — no name source involved.
Without a name source none of the three cases is recognized: findings keep
`high` confidence and the `strict` profile will not filter them out. How much
this changes the picture — see the measurement in "External configuration name
source".
Likewise, the validator does not know the form's set of attributes. An attribute
shadows a context name (configurations do have forms with attributes named `Метаданные`,
`БезопасноеХранилище`), so inside a form module the `shadowed_context_name`
finding for global-context names is off by default. Pass the attribute list via
`form_attributes` and it works there too: attribute names are excluded, the rest
are checked.
### Profiles
The `profile` parameter (or `default_profile` in the config):
- **`full`** (default) — all findings, `level` as passed. For a strong model that
discards questionable findings itself.
- **`strict`** — only high-confidence findings and a forced `level=1`. For weaker
models, so a false positive does not cause a feedback loop.
## Architecture
A Cargo workspace of nine crates:
| Crate | Purpose |
|-------|---------|
| `hbk-reader` | Reads the binary `shcntx_ru.hbk` container |
| `hbk-parser` | Parses help HTML pages (types, methods, enumerations) |
| `platform-index` | Platform index: loading, storage, search |
| `bsl-parse` | BSL parsing on top of tree-sitter: procedures, calls, query texts |
| `sdbl-parse` | Parser for the 1C query language (SDBL), recursive descent |
| `bsl-validator` | BSL expression validator and query optimality rules |
| `lite-index` | Built-in lightweight index of configuration names (SQLite) |
| `symbol-source` | Three configuration name sources: `lite`, `code_index_db`, `code_index_mcp` |
| `server` | HTTP MCP server (axum + rmcp), config, PID lock |
## Requirements
- Rust (edition 2021), built with `cargo build --release`.
- The `shcntx_ru.hbk` file from an installed 1C:Enterprise platform
(`C:\Program Files\1cv8\<version>\bin\shcntx_ru.hbk`). Not included in the repo.
## Build
```bash
cargo build --release
```
The binary is `target/release/bsl-context-rs` (`.exe` on Windows).
## Configuration
Copy [`configs/config.toml.example`](configs/config.toml.example) to
`configs/config.toml` and adjust it for your machine. Key fields:
```toml
host = "127.0.0.1" # bind, loopback by default
port = 8007 # MCP server port
platform_path = 'C:\Program Files\1cv8\8.3.27.1786' # platform version directory
default_validation_level = 1
```
### Choosing the platform version when several are installed
If multiple platform versions are installed side by side, the server does **not**
pick a version automatically — the path is set explicitly via `platform_path`.
Inside that directory it looks for `shcntx_ru.hbk` at two paths:
`<platform_path>/shcntx_ru.hbk` and `<platform_path>/bin/shcntx_ru.hbk`.
This is deliberate: method signatures and the set of system enumerations differ
between platform versions, so code must be validated against the version it is
written for. If `platform_path` is unset or the `hbk` is missing, the server starts
in both modes: the handshake and tool listing work, help tools return an error
with the reason, and maintenance tools keep working. In network mode, `/health`
reports `index_loaded: false` and the reason in `unavailable_reason`.
### Platform index cache
Building the index from `shcntx_ru.hbk` is the server's cold start: parsing tens of
thousands of help pages takes seconds, while the platform file itself does not
change between starts. The built index is therefore saved to disk and read back on
the next start:
```toml
# The field may be omitted: by default the cache lives in <log_dir>/platform-index.cache.
# platform_cache_path = 'C:\bsl-context-rs\logs\platform-index.cache'
# platform_cache_path = "" # an empty string disables the cache: the index is rebuilt from hbk every time
```
The cache stays valid as long as the platform file (path, size, modification time)
and the format version are unchanged: a platform or server update silently rebuilds
it. A corrupt or truncated file means a warning in the log and the usual build from
`hbk`; without a cache the server behaves exactly as before, it just starts slower.
Page parsing is parallelised (rayon), so even the first start takes about a second
rather than several — measured on 8.3.27 (Ryzen 5 3600): built from `hbk` in ~1.3 s,
loaded from cache in ~0.09 s, start to `listening` in ~0.35 s.
### Network deployment
By default the server listens on loopback. With `host = "0.0.0.0"` you must add
the external address to `allowed_hosts` (rmcp's DNS-rebinding protection),
otherwise networked requests get `403 Forbidden: Host header is not allowed`:
```toml
allowed_hosts = ["localhost", "127.0.0.1", "::1", "<server-ip>"]
```
The filter applies to **every** route, `/health` included: a health check over the
external address needs its entry in the list too (the supervisor's loopback probe
goes to `127.0.0.1` and is covered by the default). An empty
`allowed_hosts = []` does NOT mean "allow everything": rmcp treats an empty list
as "any Host", so the server substitutes the loopback default and warns in the log.
### External source of configuration names
The validator receives the text of a SINGLE module, so a call to a procedure declared
in another file of the configuration looks like a typo to it. A symbol source fixes
that: it answers three questions — "does the configuration declare such a method",
"is it an exported method of a GLOBAL common module", and "which exported methods does
the owner object module of an external data processor have".
Measured on a typical 1C configuration (14905 modules): without a source — 1420 high-confidence
`undeclared_method` findings; with one — 44, of which exactly one is real.
Three ways to connect it, same result:
```toml
# 1. Own lightweight index. The server stays self-contained.
[symbol_source]
kind = "lite"
root = 'C:\Repo1C' # configuration dump directory
db_path = 'C:\tools\bsl-context\ut_lite.db' # database file; the directory is created for you
```
The database is built by the `rebuild_symbol_index` tool — call it after the first start
and after every change to the configuration. It takes no parameters: the paths come from
the config. While it runs, validation keeps using the previous database; if the build
fails, the previous database stays intact. The same can be done from the command line:
`bsl-lite-index build --root <dump> --db <file.db>` (the directory must already exist).
```toml
# 2. Reading the code-index database directly (same machine only).
[symbol_source]
kind = "code_index_db"
db_path = 'C:\Repo1C\.code-index\index.db'
```
```toml
# 3. Through a running code-index service — any machine, by address and port.
[symbol_source]
kind = "code_index_mcp"
url = "http://127.0.0.1:8011/mcp"
repo = "ut"
timeout_ms = 5000
```
With no section (or `kind = "none"`) the validator behaves as before, knowing nothing
about the configuration.
How they differ. `lite` needs nothing external and answers from memory, but its database
must be rebuilt after the configuration changes. The `code-index` sources read from that
index instead — if a file watcher keeps it current, the names are always fresh. The
"global common module" flag is taken from the module's XML, which `code-index` stores verbatim.
They also differ in speed. Measured on a 261,548-function index: a direct SQLite lookup
takes 13 µs (142 ns once the names are held in memory), a networked `code-index` request
13.4 ms. The validator checks hundreds of calls per module, so prefer `lite` or
`code_index_db`; use `code_index_mcp` when the index lives on another machine.
### Several configurations on one server
One server can serve several configurations at once, each with its own access method.
Instead of the single `[symbol_source]` section, list them:
```toml
[[symbol_sources]]
repo = "ut" # configuration alias — this is the tools' `repo` argument
kind = "lite"
root = 'C:\Repo1C'
db_path = 'C:\tools\bsl-context\trade_lite.db'
[[symbol_sources]]
repo = "bp"
kind = "code_index_db"
db_path = 'C:\Repo1C-second\.code-index\index.db'
[[symbol_sources]]
repo = "zup"
kind = "code_index_mcp"
url = "http://10.0.0.5:8011/mcp"
code_index_repo = "zup-prod" # optional: only when code-index names that repository differently
```
`validate_module` and `rebuild_symbol_index` then take a `repo` argument — that alias.
**It is required whenever at least one configuration is configured**, even a single one:
a call must be unambiguous. An unknown alias is refused with the list of available ones.
`[symbol_source]` and `[[symbol_sources]]` are mutually exclusive.
With no configuration set up at all the argument is not needed: code is checked against
the platform reference only.
### What happens when a symbol source is unavailable
A source that answers "no such method" to everything is worse than no source at all: the
validator turns that into a high-confidence `undeclared_method` finding on every single
procedure call. Hence:
- **On connect**, a `code_index_mcp` source asks `code-index` for the stats of its own
repository. If `code-index` does not know it, the source is not created and the log gets
an explicit error listing the available repositories.
- **A configuration is declared but its source failed to come up** — `validate_module`
still checks the module, against the platform reference alone. Refusing would throw away
the platform-level checks too (a call to a function that does not exist, a wrong argument
count, members of platform types), and those do not depend on configuration names at all.
The answer carries `symbols_available: false` and a `degraded_reason`, so a partial check
is never mistaken for a clean one. `undeclared_method` findings are **downgraded to
`low`**: dropping them would lose the invented call this check exists for, while keeping
them at `high` would flag every call to a global common module's procedure.
- **A source dies mid-flight** (network, `code-index` down) — it is marked unhealthy, the
empty answer is not cached, and the module is re-checked against the platform alone, again
with `symbols_available: false`.
- **The source comes up later than the server** (containers starting in parallel) — it is
reconnected on the next call, at most once per 15 seconds. `symbol_sources_status` shows
the state of every configured source, and `reconnect_symbol_source` retries immediately.
The list of sources is read from `config.toml` at startup, but a `[[symbol_sources]]`
section can be added or edited on a running server too: `reload_config` re-reads the file
and swaps the whole source map, without a restart. A slot whose settings did not change
stays **the very same object** — its cache and its open `code-index` session survive; a
changed source is connected afresh; a section removed from the file leaves the map. If
`reconnect_symbol_source` is called with an alias missing from the map, it re-reads the
file first, so a section written after startup is picked up by that single call. **Only**
the source list applies: `platform_path`, `host`, `port`, `allowed_hosts`,
`[tools].enabled` and the validation defaults are read at startup — a change to them
produces a warning in the log and takes effect after a restart. When the server runs
without `--config` there is nothing to re-read, and `reload_config` says exactly that.
### Tool whitelist
If you only need part of the server's surface, list the tools you want in the
`[tools]` section. For example, module validation plus two lookup helpers:
```toml
[tools]
enabled = ["validate_module", "get_constructors", "get_enum_values"]
```
A missing section or an empty list means all fourteen tools are available, as before.
Hidden tools are absent from `tools/list` and are rejected on a direct call.
An unknown name does not break startup: it produces a warning in the log and the
tool simply never appears.
## Running
Network mode (default) — a Streamable HTTP service:
```bash
bsl-context-rs --config /path/to/config.toml
```
Healthcheck — `GET http://127.0.0.1:8007/health` (no MCP handshake required).
Stream mode — the MCP client spawns the process itself, frames travel over standard
input/output, and the log goes to standard error:
```bash
bsl-context-rs --transport stdio --config /path/to/config.toml
bsl-context-rs --transport stdio --platform-path 'C:\Program Files\1cv8\8.3.27.1786'
```
`--platform-path` overrides `platform_path` from config.toml and allows starting
without a config file. In both transport modes, if the platform is not set or the
`hbk` is missing, the server still starts: the handshake and tool listing work,
help tools return an error with the reason, while `reload_config`,
`symbol_sources_status`, `reconnect_symbol_source` and `rebuild_symbol_index` keep
working subject to the `[tools].enabled` whitelist. In network mode, `/health`
also reports the reason in `unavailable_reason`.
Prefer absolute paths in config.toml: the client, not the server, sets the
working directory.
## MCP tools
Transport — Streamable HTTP at `http://127.0.0.1:8007/mcp` (stateless) or stdio
(`--transport stdio`). In stream mode protocol frames travel over standard
input/output and the log goes to standard error; tools and their behaviour are the
same.
| Tool | Purpose |
|------|---------|
| `search` | Fuzzy search across types, global methods, properties |
| `info` | Details by exact name |
| `get_member` | A specific method/property of a type |
| `get_members` | All members of a type (methods + properties + enum values) |
| `get_constructors` | A type's constructors with signatures |
| `get_enum_values` | Values of a system enumeration |
| `validate_enum` | Validate an enumeration value. For an open collection (`open_collection: true`) a value missing from the help is not rejected |
| `validate_method_call` | Validate a global function's argument count |
| `validate_module` | Validate BSL code (whole module or fragment) against the platform |
| `rebuild_symbol_index` | Rebuild the own name index (`kind = "lite"`); paths come from the config |
| `symbol_sources_status` | State of every configured name source: connected, healthy, last connection error |
| `reconnect_symbol_source` | Retry the connection to one source without restarting the server |
| `reload_config` | Re-read `config.toml` and swap the symbol source map without restarting the server. Only `[[symbol_sources]]` applies: platform, port and the tool whitelist take effect after a restart |
| `reserved_names` | Context-occupied names from the platform help: `global_readonly`/`form_readonly` (assignment fails at runtime), `global_writable`/`form_writable` (no variable is created — the session or the form is silently changed) |
## Connecting an MCP client
Network mode:
```json
{
"mcpServers": {
"bsl-context": {
"type": "http",
"url": "http://127.0.0.1:8007/mcp"
}
}
}
```
Stream mode — the client spawns the process:
```json
{
"mcpServers": {
"bsl-context": {
"command": "C:\\tools\\bsl-context-rs\\bin\\bsl-context-rs.exe",
"args": ["--transport", "stdio", "--config", "C:\\tools\\bsl-context-rs\\configs\\config.toml"]
}
}
}
```
Two notes for stream mode: the `Host` header filter (`allowed_hosts`) does not apply —
there is no network listener; several client sessions are several processes, so their
records in the shared log file may interleave. Input is strict: a line that is not a
protocol frame terminates the session — that is almost always a client misconfiguration,
not a server problem. On Windows `rebuild_symbol_index` cannot replace a lite database
that another session or the service holds open: the call is refused with that reason and
the current database keeps working — rebuild once the other process has exited.
## Changelog
See [CHANGELOG.md](CHANGELOG.md) (in Russian).
## License
[MIT](LICENSE).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessResponsive