Skip to content
XBSL (1C:Element)
English
Esc
↑↓navigate↵open⌘Jpreview
On this page

Servers and plugins

The LSP server for editors, the MCP server for agents, the local web panel, and plugins that add rules, data and severities of their own.

One engine, several ways to reach it: a long-living server for an editor, a tool surface for an agent, a page in a browser. There is also a way to extend all of them at once.

LSP server

xbsl-lsp from the [lsp] extra (pip install "xbsl[lsp]") runs the linter as a long-living Language Server over stdio. Per-file diagnostics arrive as you type, project-wide ones on save. Go to definition, completion and hover all come off a resident project index, and quick fixes arrive as code actions. None of it pays the interpreter start-up cost per call. Flags: --project-root (the sources root relative to the workspace folder), --select/--ignore/ --enable, --data-dir, --baseline, --templates. Any LSP-capable editor can spawn it: VS Code, Neovim, JetBrains.

With --project-root the server also checks the project’s translation dictionary. Other yaml files outside the root are not checked.

The whole-project check reads only the root and the dictionary. Any other open file, such as a module outside the root, keeps the findings of its own check until you close it.

The server lives as long as the editor window, so the code and the data on disk can change under it: self-update, a git pull of an editable checkout, a plugin upgrade, a reinstall that rewrites the platform data. It keeps checking with what it loaded at start, and its findings then differ from the CLI and CI. So the server compares the engine version, the installed plugins, its code files and the platform data files it has read with the state at start, and when one of them changed it shows a warning once per change and asks for a restart (in VS Code, the command “XBSL: Restart the linter”). The checks go on, and the server does not restart on its own.

Everything an editor needs for code is standard LSP, so a plain client works with no extra wiring. On top of that the server answers private xbsl/* requests. The VS Code panels are built on them, and another editor would use the same requests to reproduce those panels:

xbsl/reindexProject waits for a fresh full project index and diagnostics pass. It returns {ok, files, diagnostics} after publication; an unsuccessful pass also carries error. Pending debounce is canceled, and an active pass finishes before the new one begins. Dirty open buffers keep their live diagnostics.

An optional workDoneToken receives standard LSP $/progress notifications with begin, report and end values. Their localized messages name the eight stages: waiting, indexing, reading sources, file rules, project rules, the translation dictionary, publishing diagnostics and completion. Indexing, reading, file rules and dictionary checks count completed and remaining files; project checks count completed and remaining rules and name the rule currently running; publication counts documents whose diagnostics have been sent. Percentages in the message apply to the current stage. The standard percentage field reaches 100 only after successful publication. Failure or cancellation ends progress without reporting full completion.

Group Requests
Diagnostics and hints xbsl/relint, xbsl/reindexProject, xbsl/hoverDoc, xbsl/templatesReload
Platform documentation xbsl/docsAvailable, xbsl/docsSearch, xbsl/docsPage, xbsl/docsTree, xbsl/docsAsset, xbsl/docsForSymbol, xbsl/docsByName
Schemas and vocabularies xbsl/uiSchema, xbsl/metadataSchema, xbsl/formKeys, xbsl/metaKeys, xbsl/metaCapabilities, xbsl/httpMethods
Metadata scaffolding xbsl/objectInfo, xbsl/metaNewObject, xbsl/metaAddField, xbsl/metaSetFieldProperty, xbsl/metaAddForm, xbsl/metaAddRoute, xbsl/metaAddSubsystem, xbsl/metaProjectInfo, xbsl/metaMoveObject, xbsl/metaDeleteObject, xbsl/metaRenamePackage, xbsl/metaMoveResource, xbsl/metaRenameResourceFolder, xbsl/metaDeleteResourceFolder, xbsl/metaResourceReferences, xbsl/metaAddLocalization, xbsl/localizationInfo
Forms xbsl/formTree, xbsl/formNodeAt, xbsl/formEdit, xbsl/searchForms, xbsl/bindingComplete
Event handlers xbsl/moduleHandlers, xbsl/addHandler, xbsl/addModuleMethod, xbsl/removeHandler

A scaffolding request returns a plan: the full text of every file it would write. The editor applies that plan as one undoable edit, and the server writes nothing itself. The CLI and the MCP server, running the same code, do write. xbsl/metaCapabilities answers with the server version and the kinds it can create - of objects, of section items, of forms - so a client can build its menus from the running engine instead of hardcoding them.

MCP server

A thin adapter over the same core. An agent such as Claude Code calls the checks as tools and receives structured diagnostics.

pip install -e ".[mcp]"
claude mcp add xbsl -- xbsl-mcp

Every writing meta_* tool applies the changes and returns the lint of the written files in the same response: creation and validation in one round trip. The lint is short: the number of files and findings, and up to ten findings one line each. lint_paths on the written files gives the whole report. The core and the CLI do not need mcp at all; it lives only in the [mcp] extra.

When the client says the transport closed. The server keeps a short journal in the user’s local state folder: when it started, how it ended and what stopped it. xbsl self-update --stop-holders stops the servers of every session to replace the package, and a client of such a server then hears only that the transport closed. xbsl mcp-log prints the journal and names the cause. Restarting the MCP client starts a new server. The journal is %LOCALAPPDATA%\xbsl\mcp-journal.jsonl on Windows and $XDG_STATE_HOME/xbsl/mcp-journal.jsonl elsewhere; the XBSL_MCP_JOURNAL variable points to another file.

When the engine on disk was updated under a running server. The server loads some of its modules only when a tool needs them. After self-update or a git pull of an editable checkout, those modules come from the new code while the rest in memory stay old, and the two halves do not fit together. Before every call the server reads the version from __init__.py on disk and checks whether the engine’s code files changed since it started: a pull between two releases changes them and keeps the number. That check looks at the few folders the files lie in, and reads the files again only when a folder changed – git replaces a file by removing it and writing it anew – or when the last reading is five seconds old, since an editor may rewrite a file in place. When the version or the files changed, every tool but version_info answers with an error that names the change and asks for a restart, plus a stale record, instead of running. The record has reason version with both versions, or reason sources with the same version on both sides and a fingerprint of the files on disk, which tells one change from the next; ran: false says the tool did not run. version_info still answers, shows engine_on_disk and carries the same stale. The code of the plugins is left to their own check (below). If a tool fails all the same, the server compares all its code files, the plugins’ included, with the state at start and names a restart when they changed; the record then carries ran: true, since the tool may have written something before it failed. A crashed rule says the same in its own report. The server does not restart or exit on its own (the supervisor does that for it), and xbsl mcp-log shows the first time it noticed each change.

When the platform data changed under a running server. A few modules take constants from the platform’s term pairs while the server imports them, and a reset of the caches does not rebuild those. A server started a moment before a reinstall wrote new data files went on answering from the old pairs, while the CLI read the new ones. So the server notes the size and the modification time of every data file it reads, and before every call compares them with the disk. The check costs one stat per file and reads nothing. When a file it read has changed or is gone, or a file it looked for and did not find has appeared, the tools refuse the same way, with reason data. This covers a plugin’s data root that gets its index.json after the start. The record lists the files under root in changed, each marked modified, added or removed. loaded and on_disk name the data versions, and fingerprint tells one change from the next. version_info carries the same record. A file the server never read does not count, because no answer came from it. A new data version still shows up, since every server reads index.json to pick its version. Nor does the documentation database count: every call opens it anew.

Only the client can restart the server; an agent that calls the tools cannot. So the refusal of a tool the CLI can run carries cli, the command line of the same call for a POSIX shell (Git Bash on Windows). The command starts the server’s own interpreter, which imports the engine from the same place and so runs the code and reads the data that are on disk now. lint_paths, lint_source, baseline_prune, list_rules, meta_fold_comments, the translate_* tools and the readers meta_project_info, meta_object_info, meta_localization_info, meta_component_tree, meta_resource_references and meta_unused_resources have such a command. A reader’s command prints the same data as the tool, without the root and file the tool repeats. The text of a lint_source call and the inline edits of a translate_set call are saved to a temporary file that the command reads, and cli_note names that file; a folder of such files older than a day, left by an earlier server, is taken out by the next one. A relative filename of lint_source goes into the command made absolute against the server’s working directory: the tool reads the name from there, and so do the rules that look for the paired yaml next to it, while the command may start from another folder. The findings of the command carry that absolute path. The interpreter starts with -P, so a folder named xbsl where the shell stands does not take the engine’s place. Python 3.10 has no such flag, and there each run of the command first changes into that temporary folder, in a subshell: (cd FOLDER && ...). The other tools refuse as before.

The plugins are compared too. When the plugins installed differ from the ones the server loaded, the tools still run, on the rules loaded at start, and every answer that is an object starts with stale: reason plugins, the changed distributions with both versions and a request to restart the server; for a tool with a CLI command the record carries cli too, the answer by the plugins on disk. version_info shows plugins_on_disk next to plugins. The server lists the installed distributions again only when a folder they are installed into changes, so a call costs one check of that folder.

Every meta_* tool and lint_paths take root, the caller’s project root. An agent working in a git worktree does not share the server’s working directory, which is why the parameter exists. Relative directory, yaml_path, module_path, paths, baseline and compare resolve against it, and the answer carries absolute paths for the written files and the diagnostics plus the root they were resolved from. Without it the server’s own working directory is used, as before. A relative path from a worktree then names the other checkout, and that checkout’s clean answer looks like yours.

Checking and the environment

Tool What it does
lint_paths(paths, select, ignore, enable, baseline, no_baseline, root, as_ci, as_ci_job, compact, fix, as_ci_full, compare, list_info) check files and directories on disk (relative paths – against root, the summary names it); paths of several project roots – two worktrees of one project are two – are never checked as one project: each root is checked the way a call of its own would check it, with its own project context, its own baseline and, with as_ci, the CI job of its own checkout, and the answer counts them all while summary.projects holds a record per root – project, its own counts, and its baseline, CI-job and fix keys; the project’s .xbsllint-baseline applies on its own, exactly as in the CLI (summary.baselined counts what it suppressed, no_baseline reports the frozen findings too; the stale entries are named in summary.baseline_stale_entries, not merely counted); enable adds a rule that is off by default on top of the defaults, the way a project asks for its translation gaps; as_ci takes the rule set and the baseline from the project’s CI job (.gitlab-ci.yml or a GitHub workflow next to the project), so a preflight judges what the job judges and the summary carries as_ci – the file and the job, the include the command stands in, the root of the checkout, the set as data (select, ignore, enable, baseline) and as one sentence (flags), the other jobs that run the linter and the includes left unread; the CLI --format json answers with the same record; as_ci_job names which of them to take - a pipeline that checks a second tree (a translation) runs the linter twice, by two different sets; the summary counts the findings by rule, by file and by severity (by_rule, by_file, by_severity), and compact narrows the answer - the summary stays, with its baseline and CI-job records, errors holds the full records of the error-level findings, and up to 10 findings (report.COMPACT_FINDINGS_LIMIT) findings lists them one line each (path:line rule – message); past 10 findings is left out and findings_hint says how many there are and how to read them, because the text of every finding is what a full answer costs: several hundred characters each when the question was only whether the tree is clean. The info-level findings are counted, not listed, and do not count towards the 10: info_hint gives their number and rules, list_info=true lists them. compact=true also omits summary.by_file, and narrows summary.as_ci to one line, brief: the file relative to the checkout, the job, the flags with a long list counted (--enable ×10), the other jobs while none was named, and the includes left unread. as_ci_full=true keeps the whole CI-job record, and compact=false gives the complete file map as well. Whatever the run found, a compact answer stays within 12,000 characters of JSON (report.COMPACT_ANSWER_LIMIT): past that the lists that grow with the run are cut from their end – the stale and reworded baseline entries first, then findings, the errors records, the records of the project roots and by_rule – truncated names every list cut with {shown, total}, and truncated_hint says how to read the whole answer. The counts are never cut. fix=true applies available edits before returning the remaining findings; the default is read-only. Accepted baseline occurrences stay protected across passes, and the baseline file stays unchanged. summary.fixed counts applied edits and summary.files_changed counts changed files. compare names a file that keeps the run for the next call, the same file the CLI --compare uses. The first call saves the run and answers compare: {file, compared: false}. Every later call compares the run with the saved one, writes it over the old one and answers {summary, compare} in place of the findings. appeared and disappeared count the changed findings, rules holds a row per changed rule (rule, files, findings, appeared, disappeared), findings lists the changed findings as records (path, line, col, rule, message), and not_compared names the paths and rules that only one of the two runs checked. Paths are matched by the folder they name, then by spelling, so runs of one folder by a relative and an absolute path compare with each other. With compact, more than 10 changes give way to findings_hint, and the whole list stays in the file under changes until the next call.
lint_source(filename, content, select, ignore) check in-memory content, before the file is written
baseline_prune(paths, select, ignore, enable, baseline, dry_run, root) remove the baseline entries this run no longer needs (the CLI --prune-baseline): the answer names every one of them – path, rule, message, count and the reason a human wrote – and the file keeps its order and format; entries of rules this server does not carry, and of files outside paths, are left alone; dry_run shows what would go; paths of several project roots are checked each on its own, and roots with baselines of their own (two worktrees) are pruned one call each – a call over them is refused and names the files
list_rules(select, ignore, filter) the rules available here: id, title, tier, scope, severity – and params for a rule that judges by a number (the value in force, the default, the overriding environment variable); select answers about one rule instead of the whole registry, and filter narrows further: a word that IS a group (the part of an id before /) lists that group alone, while any other word is looked for as an id substring or as a word of the title or the description – every i18n text registered under the rule’s id (the title and the message templates its diagnostics are built from), in either language, plus its English docstring; docs/RULES.md is not read, since it ships with neither the sdist nor the wheel. Matching is case-insensitive. A filter that matches nothing answers {error, near_groups} – the groups closest to it by spelling – instead of an empty list
version_info() what the environment is made of: engine, interpreter, data version, plugins. It tells apart two environments that answer differently on the same file. engine_on_disk and plugins_on_disk say what is installed now, and stale appears when that differs from what the server loaded or when a platform data file it read changed on disk (reason data)

Platform reference and schemas

Tool What it does
docs_search(query, limit) full-text search over the 1C:Element documentation
docs_page(id, brief, section) a documentation page by the id returned by the two other tools; brief – the head alone: a summary and the section names instead of the text, section – the head plus one section of the article (Properties, Methods, Constructors, …; an unknown name answers with the names to choose from)
docs_symbol(name, brief, section) the documentation of a symbol by name, in either spelling: a type answers with its page and the same brief and section modes, a member (Подстрока, Строка.Найти) with the block of that member alone, every overload joined - a member has no page of its own. A member several types declare answers with their names and how to ask again
type_members(name) the members of a stdlib type in one compact answer – what can follow the dot; cheaper than a page when only the member list matters
ui_schema(component, brief, property) the ui schema of an interface component: the designer’s palette and its typed properties
metadata_schema(kind, sections, names) the properties an element of a given ElementKind may declare

The three docs_* tools need the docs.sqlite database (see Documentation search), and the two schema tools read the generated language data. A type page runs to thousands of characters: the constructors, every property, the inherited lists. You want the whole article when you mean to read it. brief answers “which page is it and what is it about”, and section answers one question about it.

Translating the sources (see Translating a project)

Tool What it does
translate_status(root, against, full) the coverage and what is left, the cheap check before deciding anything. A root without a dictionary is refused, and the answer names where a dictionary was looked for; against names a git ref and adds collisions - the keys the dictionary files of the working tree and of the ref translate differently or the same way, the report of xbsl translate --check-duplicates. The duplicates the ref already has come back the same on every call of a branch, so they are counted rather than listed (duplicates_total, duplicates_at_ref); full=true lists them all. The conflicts are listed whole either way. drift counts the phrases translate_drift lists
translate_gaps(root, kind, filter, limit, offset, compact) what the dictionary does not cover yet, by page: the count, the first places, the platform’s own spelling as a hint; compact keeps only the key, the kind and the count per row; the answer names the dictionary it read
translate_entries(root, kind, filter, limit, offset, compact) what the dictionary already says, with the file and line of each entry; ten rows a page by default, and compact keeps only the key, the kind and the value of each row
translate_unused(root, kind, filter, since, limit, offset, prune, compact, budget_seconds) dictionary entries no longer used by the project; filter accepts a substring or a list, since scopes candidates to one change. Preview lists full rows by default; compact=true keeps only key, kind, file and line. prune=true removes every key the filters select, whatever the page, with all its declarations, and normally omits the list: removed counts occurrences, pruned.keys counts pairs, and pruned.by_kind / pruned.by_file group them. Explicit compact=false includes full rows after removal. counts covers the whole filtered candidate set, and so does prune: pagination shapes only the list. budget_seconds (300 by default) bounds scanning; a partial answer lists candidates and never removes entries.
translate_set(root, edits, edits_file, target, comment) write entries back: add, correct in place (in every place the key is declared), or remove by emptying a value; edits_file is a batch file (the dictionary’s own yaml format, read by the dictionary’s loader, so a batch dumped with every key in quotes reads too, or the JSON list; a batch with no entries is refused, naming the top-level keys it has), comment is the head line a newly created file gets. Every plane is held to the spelling its pass reads: an entry that could not fire comes back in refused, and one with a single obvious reading - a phrase whose quote is escaped the literal way - is written by that reading and listed in normalized

All four answer in pages over one engine core, so filling a dictionary of thousands of entries never means reading the files.

The project and its objects

Tool What it does
meta_project_info(root, kind, subsystem, brief, package, project, reference) map the sources under a root: projects, subsystems, packages, objects by kind; kind, subsystem and package narrow the list of objects, project walks only the named project, and the reference sections (the object kinds, the section kinds, the access methods) come with reference or brief
meta_object_info(root, name, yaml_path) describe one object: everything needed to write its forms and code
meta_new_project(...) scaffold a project: Проект.yaml, Проект.xbsl and the first subsystem
meta_new_object(directory, kind, name, ...) create an object: <Name>.yaml plus <Name>.xbsl for kinds with a module; presentation and object_presentation write the list and object captions of a catalog or a document, record_presentation the record caption of an information register or a periodic constants set, periodicity makes a constants set periodic
meta_rename_object(..., dry_run, full) rename an object and update every reference across the sources. The answer is short: the renamed files, a note with the counts, and outside_projects – the edited files no project owns, such as a translation dictionary beside the project. full=true lists every edited file
meta_delete_object(..., dry_run) delete an object whole: the yaml/module pair, its forms and the WSDL descriptions of a SOAP service client
meta_move_object(root, yaml_path, target_dir, dry_run) move an object with its forms, modules, list row and list table into a package, another package, the subsystem root or another subsystem; the imports a reference needs at the new place, the qualified names of the old place and Using are repaired by the import rules run before and after the move, and a taken name or a non-public element another subsystem would reach is refused
meta_rename_package(root, package_dir, new_name, dry_run) rename a package folder with all its files and rewrite import Subsystem::Package, the Import items and the qualified names across the project
meta_move_resource(root, resource_path, target_dir, dry_run) move a resource file or a folder into another folder of the same Resources folder and rewrite the Resource{...} keys and image property values that lead to it; lookups by a string are listed in notes, a move into another Resources folder is refused
meta_rename_resource_folder(root, folder_dir, new_name, dry_run) rename a folder inside a Resources folder with all its files and rewrite the keys that name them
meta_delete_resource_folder(root, folder_dir, dry_run) delete a folder inside a Resources folder with its files and list the keys and string lookups that name them; dry_run defaults to true
meta_resource_references(root, resource_path, limit) find the places that name a resource file or a folder: Resource{...} keys and image property values, keys two folders hold, strings with the path or with the file’s key without its extension; each place has a file, a range, its line and a kind; limit (100 by default) caps the list, total counts every place, and hasMore is true when the cap left some out
meta_unused_resources(root, include_protected, limit) find resource candidates with no known uses; computed and uncertain uses remain separate, every candidate needs review, and no file is deleted
meta_add_subsystem(parent_dir, name, ...) create a subsystem folder with its Подсистема.yaml
meta_add_dependency(root, vendor, name, version, ...) attach a library – the Libraries section of Проект.yaml
meta_set_access(root, ..., default, permissions, calc_by) set AccessControl.Permissions on an object

Fields, routes, methods, forms, localization

Tool What it does
meta_add_field(yaml_path, field_kind, name, type, props, names, doc, ...) add a section item: attribute, dimension, resource, enumeration value, parameter, field, a property of a contract or of an interface component (a name and a type, no Id), tabular section. doc is the item’s description, written as its documentation comment: the ## lines at the head of the item, which the development environment reads and keeps; it is refused for a built-in attribute, a localized string key and a batch of several names. names adds several items of one kind in one call with the same type and properties, such as the values of an enumeration; the batch is planned whole, and a taken name refuses all of it. A built-in attribute (Number, Date, Code, Name, Owner) is judged by its own class, so Length, Uniqueness and the Autonumbering block are accepted and type may be omitted where the class fixes it; props takes a nested block as a dict or a dotted key (Autonumbering.Prefix) and a list as a sequence; a block of a class the metamodel does not describe (Presentation) is refused as a known limitation; a section the file lacks is created at the end of the file (a component’s Properties in front of its Events, the order the designer writes), and for a register notes say so and name the sibling kind that would have placed the item beside the existing fields (a resource where an attribute was asked, and the other way round)
meta_set_field_property(yaml_path, field_kind, name, props, ...) set properties on a section item that already exists; the same value shapes as meta_add_field, a nested block replaces the old one whole, and a Type is read like its type: markup escapes undone, platform names in the language of the file
meta_add_route(yaml_path, routes, template, methods) add url templates to an HttpService plus the handler stubs
meta_add_method(module_path, name, params, returns, ...) insert a method into an .xbsl module without tearing annotation blocks apart
meta_add_form(root, ..., forms, card_min_width, card_placeholder) generate forms for an object and register them in its Interface
meta_add_localization(yaml_path, language) add a translation file to a localized-strings element
meta_set_localization(yaml_path, name, values, entries, section, dry_run, full_text) write one or many localized strings into every language at once - the default-language text into the element, each other language into its own translation file; a language a key says nothing about still gets the row, with the default text and a note. entries writes many keys ({key: values}) in one pass, one read and one write per file however many keys touch it, and either fails as a whole before anything is planned or not at all. dry_run answers with summary (key, language, file, the text before and after) instead of the whole files; full_text adds the files back next to it
meta_localization_info(yaml_path) the localization picture: declared languages and what is still untranslated

Form components – the designer, scripted

Tool What it does
meta_component_tree(yaml_path, node_id, name, max_depth, properties, brief) the node tree of an interface component; a big form can be taken in parts - a subtree (by node id or by its Name), a depth limit, without the property records, or as the skeleton alone (brief - ids, kinds, types, names and slots, a few kilobytes for a tree of hundreds); a big whole tree carries a hint naming these knobs
meta_add_component(yaml_path, parent_id, slot, ...) insert a new component into a slot of the parent node
meta_insert_fragment(yaml_path, parent_id, slot, fragment, ...) paste a ready yaml block of one component (a copied subtree) into a slot. A # note of the fragment goes where the development environment reads it: a comment above the component moves inside the node as ## lines, and a note with no such place stays and is named in notes, see Comments in yaml
meta_fold_comments(paths, dry_run, take_proposed, root, compact) fold the comments the development environment does not read into the description of the nearest node that has room, as xbsl fold-comments does, see Comments in yaml. The answer is the report of --format json: the moves of each file, the audit, the number of files written and the moves counted by kind and action. dry_run is true by default and nothing is written; dry_run=false writes the files that pass the audit, and take_proposed applies the ambiguous moves too, like --all. Over a whole tree the report runs to hundreds of kilobytes, and compact gives the short one: the counts, the files with the most moves and the moves worth a look (the ambiguous and the left ones first), up to ten lines each, plus every file the audit stopped
meta_move_component(yaml_path, node_id, new_parent_id, slot, ...) move a node into another (or the same) slot; the comments above it travel along
meta_move_components(yaml_path, node_ids, ...) move several nodes in one operation, keeping their document order
meta_remove_component(yaml_path, node_id) remove a node with its attached comments
meta_remove_components(yaml_path, node_ids) remove several nodes in one operation
meta_set_component_property(yaml_path, node_id, key, value, value_yaml) set, replace or remove a property of a node
meta_add_handler(yaml_path, node_id, key, method, signature) bind an event property to a handler method of the paired module

Every tool that takes a node accepts its path from meta_component_tree or the Name of the component, when no other component in the form carries it. A path is positional and shifts after an edit; a name stays. A repeated name is refused with the paths to choose from.

The same operations are available through the CLI (Commands) and, for an editor, through the xbsl/meta* LSP requests.

A supervisor that replaces the server

xbsl-mcp runs the server behind a supervisor, which keeps the client’s session through an update. The supervisor holds the stdio the client speaks over and runs the server as a worker process behind it: python -m xbsl.mcp_server on the same interpreter, with its stderr going to the client’s log. It remembers the client’s initialize, and when a worker has to go, it starts a new one and repeats that handshake to it. The client goes on talking to what looks like the same server and needs no restart.

claude mcp add xbsl -- xbsl-mcp

python -m xbsl.mcp_supervisor is the same command, and xbsl-mcp-supervisor stays as another name for it. The supervisor costs one more small process and one more hop per message. A client that wants the bare server starts xbsl-mcp --no-supervisor, which runs it in the same process, or python -m xbsl.mcp_server; then an update of the engine ends in a refusal that asks for a restart, as described above.

self-update replaces the package but not the command stubs, and a stub made by an older release starts the bare server. Such a stub hands its process over to the supervisor before the engine loads, so after an update xbsl-mcp runs the supervisor as well. A command that a newer release adds, such as xbsl-mcp-supervisor, gets its stub only from pip install --upgrade or pipx reinstall.

The worker decides when it has to go, and the supervisor reads that from its answers:

  • a refusal with stale.ran: false means the tool did not run: the engine on disk has another version, its code files changed under the same one, or a platform data file the worker read changed. The supervisor sends the same call to a new worker, and the agent gets the answer of the new code and data instead of the refusal. version_info is asked again the same way, because it only reads. A worker started after one change of the files is replaced after the next one too: the fingerprint of the record tells the two apart;
  • an answer whose stale says the tool ran (it failed on a mix of the old and the new code, or the plugins on disk changed) reaches the agent as it is, because the tool may have written files. The next call goes to a new worker;
  • a worker that failed, or that self-update --stop-holders stopped, is replaced at the next call rather than at once, so that a new worker does not load a package the update is still unpacking. A call that worker was running comes back as an error: nobody knows what it had written by then. A request that only reads goes to the new worker.

The supervisor itself loads no engine and keeps no file of the package open. self-update does not count it as a holder and does not stop it, and it never runs old code of its own; the holders are the worker and a server started with --no-supervisor. An xbsl-mcp still running the bare server of an older release has no worker under it, and self-update stops it as a holder. An old worker is not killed in the middle of a call: it gets no new calls, finishes the ones it has and then ends. After a new worker takes over, the client is told that the tools list changed and asks for it again, so the tools and parameters an update brings are there without a restart. xbsl mcp-log names every replaced process and why it was replaced. When the client closes its end, the workers get the end of their input as well, and the supervisor ends once they have.

The repeated initialize asks for the protocol version the client asked for, and a new worker may agree on another one than the client was answered at the start: an update of the mcp package can drop the version the client speaks. The supervisor changes nothing then – the client keeps its version and the new worker serves on – but writes both versions into the journal, and xbsl mcp-log shows them next to the replacement. When a client misbehaves after an update, that line is the place to look. A worker that agrees on the same version leaves no such line.

Web interface

A local page: point it at a project folder and see the diagnostics. It is written on the standard library alone, with no external dependencies, and it binds to 127.0.0.1 only.

xbsl-web            # then open http://127.0.0.1:8771/

The page has per-tier rule toggles, a data-version selector, severity and text filters, and a dark and a light theme. Clicking a diagnostic opens the file in VS Code (vscode://).

Extending: your own rules, data and severities

Three entry point groups let a separate package extend the linter without forking it. This is for teams whose rules or language data cannot be published: keep those in a private package that depends on xbsl.

# pyproject.toml of your package
dependencies = ["xbsl>=1.0.0"]

[project.entry-points."xbsl.rules"]
myproject = "myproject.rules"        # importing the module runs its @rule decorators

[project.entry-points."xbsl.data"]
myproject = "myproject:data_root"    # a path, or a callable returning one

[project.entry-points."xbsl.severity"]
myproject = "myproject:severity_overrides"   # {rule id: "error"|"warning"|"info"|"off"}

The severity dict, or a zero-argument callable returning one, raises and lowers the default level of any rule, built-in or plugin, for every run in this installation. A project may treat, say, style/abbreviation-case as a warning while the published default stays info. "off" removes a rule from the default set; an explicit --select or --enable still turns it on, at its base level.

Install the package and the CLI, the MCP server and the web UI all pick everything up. No flags, no config file. A failing entry point raises rather than printing a warning, because a linter that silently drops a rule stays green in CI and guarantees nothing. An override naming an unknown rule id or level raises for the same reason. XBSL_NO_PLUGINS=1 ignores every external package, so only built-in rules, bundled data and default severities remain.

Last updated on October 2, 2026

Was this page helpful?