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.
| 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, the server starts and `/health`
responds, but the MCP tools return `503` with a hint to set the path.
### 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>"]
```
### 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.
### 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 thirteen 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
```bash
bsl-context-rs --config /path/to/config.toml
```
Healthcheck — `GET http://127.0.0.1:8007/health` (no MCP handshake required).
## MCP tools
Transport — Streamable HTTP at `http://127.0.0.1:8007/mcp` (stateless).
| 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 |
| `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
```json
{
"mcpServers": {
"bsl-context": {
"type": "http",
"url": "http://127.0.0.1:8007/mcp"
}
}
}
```
## Changelog
See [CHANGELOG.md](CHANGELOG.md) (in Russian).
## License
[MIT](LICENSE).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessWithin a week