What the promise does NOT cover, because this server bundles no content: the
documents it serves (the guide, the client interface, the pitfall catalogues,
the capability map, the sample catalogues, the docs) are read live from the
abap2UI5 repositories and change when they do, and the backend runs the
@abap2ui5/node-runtime release the npm registry names unless
A2UI5_MCP_RUNTIME_VERSION pins one. Human-readable text - a description, a
degradation message, a hint - is for the agent to read, not to parse.
-
30 tools. setup_status (what works on this machine right now, and
why not); the knowledge half - capabilities, examples, read_example,
app_guide, api_reference, generation_rules, scaffold_app,
docs_search, pitfalls, scope_of; the project's own setup -
add_agent_setup; the cheap view half, seconds and no backend -
validate_view, fix_view, screenshot_view; the dev sandbox -
deploy_app, read_app, remove_app; the expensive half on a real
transpiled backend - build_backend, build_log, backend, run_app,
interact_app, run_unit_tests, and verify_app, the whole loop in one
call; operating a running app without a browser - app_list,
app_start, app_describe, app_act; and migrate_report, a classic
ABAP report as an abap2UI5 app class.
-
Six resources (abap2ui5://guide, abap2ui5://api,
abap2ui5://capabilities, abap2ui5://generation-rules,
abap2ui5://pitfalls/abap, abap2ui5://pitfalls/view) plus the
per-chapter template abap2ui5://guide/{chapter}, the MCP Apps screen
ui://abap2ui5/app-screen the app tools render into, and two prompts,
build-an-abap2ui5-app and port-a-ui5-sample.
-
No checkout needed: the knowledge tools fall back to a read-only GitHub
mirror, and the expensive half runs on @abap2ui5/node-runtime without a
framework clone; a sibling checkout or a set *_HOME variable still wins,
and a misconfigured one is reported, never worked around.
-
Three bins - mcp-server, abap2ui5-mcp, abap2ui5-unit - and the
composite GitHub Action abap2UI5/mcp-server@v1, which runs an app
repository's ABAP Unit tests without an SAP system. The release workflow
moves the floating major tag to v1 with this release; v0 stays at
0.3.0, so a workflow pinned to @v0 keeps working and moves to @v1 when
its owner decides to.
-
Listed in the official MCP Registry as io.github.abap2UI5/mcp-server.
server.json describes the npm package (stdio, npx) and every
environment variable the server reads - all optional, none with a default,
because a set variable is authoritative here. package.json carries the
mcpName the registry checks npm ownership by, and the release workflow
publishes the listing after the npm publish, in a job of its own, logged in
with GitHub OIDC (no secret) through a pinned, checksum-verified
mcp-publisher. npm run check:server-json (CI, the release workflow and
npm test) fails when server.json's versions, package name or registry
name drift from package.json, or when its variable list and the code
disagree in either direction; npm version keeps the versions in step by
itself (the new version script).
-
One-click install in the README: VS Code and VS Code Insiders badges, a
Cursor badge and the direct vscode: / cursor:// links beside the
claude mcp add line - all registering npx --yes -p @abap2ui5/mcp-server abap2ui5-mcp, which test/install-links.test.mjs decodes from every link
and holds against the registration the README shows in clear - and the
Claude Code plugin (/plugin marketplace add abap2UI5/abap2UI5, /plugin install abap2ui5@abap2ui5), which bundles the framework's agent skills with
this server.
-
The linter is a declared peer dependency, and npx -p @abap2ui5/mcp-server
brings it along. The server had no dependency on @abap2ui5/linter at
all: the registration every document gives installed a server whose
validate_view, fix_view and screenshot_view were dead unless the user
also typed -p @abap2ui5/linter -p @abap2ui5/linter-render (the VS Code
extension 0.30.1 grew a warning for exactly this). package.json now names
@abap2ui5/linter as a regular peer at >=0.8.0 <0.9.0 - npm 7+ installs
a non-optional peer by itself, for npm install and for npx -p alike,
hoisted beside the server where the sibling lookup already finds it, so
the plain one-liner carries the property gate (1.9 MB) - and
@abap2ui5/linter-render as an optional peer in the same range: the
compatible range is declared, the 123 MB of UI5 behind the render gate is
not installed for you (npm never installs an optional peer); add
-p @abap2ui5/linter-render for screenshot_view. The resolver gained a
last candidate after the checkout siblings, the project's and the server's
own node_modules: the @abap2ui5/linter Node's own module resolution
finds from the server's location, for a hoist the explicit paths cannot
name. test/view-check-install.test.mjs pins the npx layout, a nested
install, that deeper hoist and the project's own devDependency against
copies of lib/. The missing-linter hints and the README say what npm
installs and what it only declares.
-
add_agent_setup: an existing project set up for AI work. What
npm create abap2ui5-app -- --agent-setup does, for any MCP client's
agent: app-template's template.json agentSetup key, executed over the
project directory the agent names (project_dir; the server's working
directory by default, when it has a .git, .abapgit.xml or
package.json) - AGENTS.md, CLAUDE.md, the agent skills, .mcp.json, the
.claude allowlist, abaplint.jsonc and abap2ui5lint.jsonc, the CI
workflow and the scripts it runs. A file the project has is never
overwritten (skipped, and named); package.json and .gitignore are
merged and only gain entries, every value kept that differs from the
template's a warning; the configs follow the project's .abapgit.xml
STARTING_FOLDER, and nothing is written into that folder; a second run
changes nothing; dry_run answers the same report and writes nothing. The
answer: written, merged (with what was added), skipped (with why),
warnings (kept values, an abaplint.json beside the new abaplint.jsonc,
a missing source folder, the framework pin check:pin would fail on) and
next. The template comes from the checkout or the read-only mirror, as
for scaffold_app; every path it lists is checked before anything is
written (a plain relative path, a shared file of the template, outside the
source folder - else the whole call refuses), a symbolic link is never
written through, and the file system root, the home directory, the
server's installation, the template, the mirror cache and the workspace
are refused as targets. A tool of its own rather than an option of
scaffold_app, which hands a NEW project back and writes nothing.
lib/agent-setup.mjs ports the template's create/agent-setup.mjs
planner; test/agent-setup.test.mjs drives the tool over stdio (a fresh
directory, an existing project skipped and merged, another
STARTING_FOLDER, idempotence, the refusals, symbolic links, the pin
check) and compares its plan byte for byte with the create package's when
an app-template checkout is beside this repository.
-
Every abap2UI5 app is agent-operable: app_list, app_start,
app_describe, app_act. interact_app drives an app with CSS
selectors in a headless Chromium and answers with a picture; the new tools
speak the abap2UI5 JSON protocol itself against the local backend - the app
start, the event with its arguments, the model delta of what was typed,
exactly as the UI5 frontend sends them - and answer with an agent
snapshot v1: the fields an agent may fill (model path, label, kind,
value, choice values, required, editable), the actions it may fire (event
name, static arguments, row-dependent ones as $row:FIELD), the tables
(columns, the first rows, selection mode, editable cells), the messages
(toast, message box, MessageStrip, value states, the app's message table)
and some static text, from the view XML of every open slot (main, nested,
popup, popover) and the model. An act is validated against the snapshot
before anything is sent - an unknown event, a field that is not on the
screen or not editable, a choice outside its values is refused naming what
is allowed; values without an event stay pending, as typing does in the
browser; the popup close the browser performs alone is @CLOSE_POPUP.
app_describe answers from the last response kept, no roundtrip.
lib/snapshot.mjs (pure: the frontend's slot and model bookkeeping, and
the snapshot), lib/viewxml.mjs (the view, binding, expression and
event-wire parsers) and lib/appclient.mjs (the roundtrips, sessions and
validation); the linter's UI5 metadata, when it resolves, classifies
controls the snapshot has no entry for. The shape is shared with the VS
Code extension and the ABAP agent addon: docs/agent-snapshot.md is its
reference, including where it deviates from the contract it started from
(no /XX/ two-way prefix in the current protocol, pending values,
selectionField, @CLOSE_POPUP) and what it cannot see yet.
test/snapshot.test.mjs and test/appclient.test.mjs run on eleven
recorded sample sessions (test/fixtures/agent/, real request/response
pairs of abap2UI5/samples apps on @abap2ui5/node-runtime 1.146.0) - the
replay insists on the exact request the real run sent;
test/agent-integration.test.mjs operates a form, a table with a row
action and selection, and a popup on the published runtime, through the
client and through the server over stdio (network-gated like
test/npm-integration.test.mjs).
-
The app client embeds without wrappers. createAppClient takes
transport (one roundtrip: the serialized request with its headers,
timeout signal and the draft id it continues in, { status, headers?, body } out), location (the app start's ORIGIN/PATHNAME/SEARCH),
an optional generation (absent: no restart detection) and backendHint
(the words after "the backend did not answer (...)"). The defaults are the
local backend's, so the MCP server's requests and refusals are unchanged
(the recorded sessions replay as before); the VS Code extension, which
vendors the client for a real SAP system, drops the request rewriting and
message patching it needed. docs/agent-snapshot.md "Embedding the
client" is the reference; test/appclient.test.mjs covers every option.
-
migrate_report: a classic ABAP report as an abap2UI5 app. The source
of a report (and optionally its .prog.xml text pool and the class name)
in; the class of the abap-cloud-gui addon out - INHERITING FROM z2ui5_cl_cgui_report, the selection screen as selection_screen( ), the
event blocks as its methods, the list as write( ), the ALV as alv( ) -
with the migration report (TODOs, unreleased tables and APIs with their
successors, what was not carried over, every mapped construct) and the
refusals (file:row:col and the reason; no class unless partial). The
converter is the addon's report2cloud, imported from an abap-cloud-gui
checkout (ABAP_CLOUD_GUI_HOME or ../abap-cloud-gui, npm ci done) -
not bundled, not on npm, and reported with the clone command when it is
missing (lib/siblings.mjs, setup_status lists it). deploy: true
writes the class with the addon's src/01 and the popups it calls into the
dev sandbox, builds the backend and answers app_start's snapshot of the
selection screen, stage by stage like verify_app. lib/migrate.mjs;
test/migrate.test.mjs converts a fixture report and a refused one (skipped
without the checkout) and runs the deploy on @abap2ui5/node-runtime
through Execute (behind the network gate).
-
Agent snapshot: selection dialogs and message lists. SelectDialog
and TableSelectDialog are tables now (control names them,
selectionMode from multiSelect, the dialog's title titles its layer),
and their confirm is a row action: app_act({ event, row }) picks the row
as a click does - its selectionField (selected="{ZZSELKZ}") set, the
other rows' cleared in single select, all sent as the model delta - and
fills the confirm's $parameters from the selected rows
(selectedItem, selectedItems, selectedContexts). The row-valued
parameters of table row events (listItem, rowIndex, rowContext,
rowBindingContext, a row action item's row) are filled from row too,
for the shapes views write (.getBindingContext().getProperty('X'),
.getPath(), .get<Prop>(), .getCells()[n].get<Prop>(), the ? :
guard); paths follow UI5's JSONModel, so selectedContexts[0]/sPath is
null as in the browser. Anything else is refused naming args[i]. The
items of a MessagePopover / MessageView are messages with source
popover / messageview and the optional subtitle and description.
All additive - snapshotVersion stays 1, and docs/agent-snapshot.md now
says what may change within a version. New recorded sessions: samples-
controls 623 (SelectDialog), samples 452 (MessageView, MessagePopover) and
two abap-cloud-gui report2cloud reports (the F4 TableSelectDialog of the
popups, the message popover); the agent integration test picks a row
through app_act on @abap2ui5/node-runtime.
-
The agent client follows the protocol's frontend rules (abap2UI5/protocol
frontend suite, adapter agent: 53 pass / 5 MUST failures / 3 warnings
before, 61 pass / 0 / 0 now; the 20 skips are capabilities a client without
a browser does not have). lib/appclient.mjs: a response declaring a
PROTOCOL other than 2 is refused whole - its draft id, view and model -
with both numbers named (an absent one is let through); the last
sap-contextid response header of a session is sent with every later POST
of that session (never empty, never undefined; a response without it
keeps it), so a stateful ABAP app keeps its work process; a token layer's
403 + X-CSRF-Token: Required is answered by a HEAD token fetch and
one re-send of the same body, the token then sent with every POST (a 403
without Required stays final); one roundtrip at a time per session - an
app_act with an event while another is in flight waits for it and runs on
the draft id it left, instead of posting the same draft id twice; a
roundtrip clears only the edits it carried, so values set while it is in
flight stay pending, survive its model push and travel next (a failed one
rolls back only its own edits); the error body is shown verbatim - no tag
stripping, no entity decoding, only shortened (40 lines, 4000 characters)
and control characters shown as U+FFFD. lib/snapshot.mjs
applyResponse: a response of another APP tears the popup and the
popover down. Embedders: the transport option now also receives
method ('POST', or 'HEAD' for the token fetch, without a body) and
the sap-contextid / x-csrf-token headers to send; the client reads
both from the answer's headers and does the handshakes itself, so a
transport must not repeat them. New exports: PROTOCOL, headerOf,
validContextId. docs/agent-snapshot.md points to the normative
semantic profile (abap2UI5/protocol profiles/semantic.md).
-
Database tables in the npm backend's sandbox. A sandbox's transparent
tables (TABL, with their data elements) are transpiled with the classes
and created in the runtime's SQLite database at boot: the build takes
their CREATE TABLE out of the transpiler's init.mjs and apps/init.mjs
runs it after the package's boot, before the apps load (a structure is a
TABL too and creates nothing; a sandbox without tables builds and boots as
before; a transparent table the transpiler wrote no statement for fails the
build with that reason). migrate_report { deploy: true } deploys
abap-cloud-gui's tables and data elements with its classes - since its
PR #8 the variant and layout stores (z2ui5_cgui_var, z2ui5_cgui_lay)
are the default, and the deploy failed at app start with Void type: Z2UI5_CGUI_VAR; abap2ui5-unit deploys a project's *.tabl.xml /
*.dtel.xml too, so a class under test can INSERT and SELECT its own
tables (test/fixtures/table-app, run in test/npm-integration.test.mjs).
A framework checkout (and the clone of A2UI5_MCP_BACKEND=clone, once
build_backend made it) already created every deployed table through the
framework's own setup; without a clone yet there is no sandbox, as before.
-
interact_app no longer waits for networkidle. After the actions
(each of which waits for its own roundtrip) it asked Playwright for no
request of any kind for 500 ms, up to 10 s, before the quiet-backend
tail - and a page with a long poll or a slow CDN download spent the
whole 10 s there on every call. The tail is the backend-quiet wait alone.
-
The local UI5 lookup reads the @openui5 directory once. Every UI5
request of a boot (hundreds) re-read the corpus' node_modules/@openui5,
stat'ed each package's src and then exists+stat'ed the file;
libRoots is memoised on the directory (an install moves its mtime) and
a file costs one stat per root (0.07 to 0.04 ms a request on ten roots,
the directory read 0.04 to 0.008 ms).
-
A build or a backend stop keeps the Chromium. stopBackend closed
the browser run_app/interact_app share, so every real build and every
stop cost the next call a browser launch. It now closes the contexts open
on the backend (their pages show a dead app) and leaves the browser to
the shutdown (closeBrowser).
-
The warm lint worker exits when idle. With the framework parsed it
holds about 300 MB, and it held them for the server's life. After
A2UI5_MCP_LINT_IDLE_MS (default 10 minutes; 0 keeps it) without a
request it exits, and the next lint starts a fresh worker (the cold
parse once more); a lint that arrives while it exits starts one at once.
-
The GitHub mirror backs off exponentially. A failed download was
tried again every 3 minutes, and a machine that is offline without
A2UI5_MCP_OFFLINE stalled every mirrored tool call the 20 s fetch
timeout that often, for as long as the server ran. The wait now doubles
with each failure in a row per repository (3, 6, 12 min ... up to the
mirror TTL) and starts over at a success; the hydrate answer carries
failures and backoffMs.
-
npm test in well under a minute. The three tests that install the
published runtime from the registry (npm-integration's loop and sandbox
table, agent-integration's app tools, migrate's deploy) are opt-in under
A2UI5_MCP_NETWORK_TESTS=1, which CI sets; the backend lifecycle tests
wait for the port and the child's exit instead of sleeping 1.4 s after
every stop, and turn the start's liveness wait and port poll down
(backendTiming, a test hook, not an environment variable).
-
run_app / interact_app answer a failed UI5 bootstrap at once.
With the CDN unreachable (and no local @openui5 packages) the boot
waited its whole timeout - 60 s - although the bootstrap request had
failed within milliseconds; the wait now ends the moment sap-ui-core.js
fails (64 s to 2.8 s measured), with the same report. Under
A2UI5_MCP_OFFLINE with nothing local to serve, the report is the
answer before a backend or a browser is started.
-
UI5 from the CDN is downloaded once, not per call. Each
run_app/interact_app boots in a browser context of its own, whose
HTTP cache is empty, so every call fetched the bootstrap and a few
hundred modules from sdk.openui5.org again. The CDN route now keeps its
successful answers in memory across calls, bounded by
A2UI5_MCP_UI5_CACHE_MB (default 64; 0 switches it off), the oldest
evicted first; the local @openui5 packages and the offline 404 come
before it, and the per-call context isolation stays.
-
A build nothing changed for is not made. build_backend (and the
build stage of verify_app and migrate_report { deploy: true })
always stopped the backend and transpiled the sandbox - 7-8 s on the npm
backend, 1-2 min on a checkout - and every app session on the backend
died with the stop. The build record now carries a fingerprint of what
the build was made from (the sandbox's files by name and bytes, the
release, the transpiler version, the open-abap-core commit; on a
checkout the transpile config and node/downport by name, size and
mtime, recorded with the init.mjs the transpile wrote), and a build
with the same fingerprint answers built: true, unchanged: true at once:
nothing transpiled, the backend not stopped, its sessions intact. The
stop (beforeBuild) moved into the build, called only when one really
starts; syncDevCopies leaves a copy that already holds the sandbox's
bytes untouched (its mtime is part of the fingerprint) and reports what it
wrote under changed.
-
The lint runs warm. Every deploy spawned a fresh abaplint --format json over the sandbox and the whole framework, 2.6 s of which was the
framework's parse, the same each time. One long-lived worker
(lib/lint-worker.mjs, driven by lib/lint-host.mjs) now keeps an
@abaplint/core Registry with the dependency folder parsed and lints a
deploy by replacing the sandbox's changed files in it (900 ms cold, 9 ms
warm against the real core); the npm backend installs @abaplint/core at
@abaplint/cli's version beside it for exactly that. The answer is the
CLI's JSON shape, byte for byte; the CLI runs whenever the worker cannot
stand in for it (no core of the CLI's version - a framework checkout -,
a config it does not mirror, a worker that died) and under
A2UI5_MCP_LINT_WORKER=0. The timeout and the client's cancel kill the
worker and read as before.
-
Less work per call across the cheap half. The repository resolvers'
identify check parses catalogue.json once per version of the file
(readCached, with a scope that keeps two parses of one file apart);
examples computes each entry's haystack, missing terms and keyword score
once before ranking instead of on every comparison; docs_search reads
lowercased copies cached with the page slices instead of lowercasing the
corpus per query; app_list reads the transpiled class modules once per
build (memoised on the output folder's init.mjs); spawnWithTimeout
accumulates a child's output in chunks and cuts the kept tail once at
close instead of flattening it per chunk (quadratic at the lint's 64 MiB
cap); validate_view's warm render gate keeps two pages, and
A2UI5_MCP_RENDER_PAGES (1 to 8) sets it.
-
run_app and interact_app wait for the backend to be quiet, not for
a fixed 600 ms. The tail after the boot and after the action script
(quietAfter in lib/runtime.mjs, on the in-flight count trackRequests
keeps) ends once every backend request has answered and none went out
for 250 ms - at least 250 ms for the render to settle, at most the 600 ms
it always was; the wait after an action (settle) is the same helper
behind its 100 ms grace. Unit-tested on a fake clock.
-
The linter's 0.9 line is admitted. The peer ranges on
@abap2ui5/linter and @abap2ui5/linter-render are >=0.8.0 <0.10.0
(were <0.9.0), so a project on linter 0.9.0 installs this server
without an ERESOLVE, and npx -p @abap2ui5/mcp-server brings 0.9.0
along. The exports the server imports (., ./findings, ./config,
./rule-docs and the shapes behind them) are unchanged in 0.9.0 - the
linter's own downstream job runs this server's smoke against it.
-
abap2ui5-unit refuses a path without classes before it installs
anything. A typo in the path (or a run from the wrong directory) was
found out only after the framework install and the backend build - a
minute or more of a CI job, tens of minutes on a checkout's full build -
and then ended with the same no *.clas.abap under ... and exit 2. The
classes (and --class names) are read first now.
-
A backend that cannot bind its port says how to move it. With the
framework checkout's own express server, a port already in use (3000 is
every dev server's default) ended run_app, app_start and
backend start with the bare listen EADDRINUSE line. The answer now
adds which port is taken and that A2UI5_MCP_PORT moves it, as the npm
backend's host already did.
-
validate_view and fix_view refuse a min_ui5 that is no version.
Any string went through and was compared as if it were a floor:
min_ui5: "abc" answered "newer than the abc floor", and a typo such as
"1,120" judged the view against a floor nobody meant. The argument is
checked the way the linter's CLI checks --ui5 (1.71, 1.120.0), and
anything else is an error naming the value.
-
The npm backend builds on the transpiler 2.14 layout. abap2UI5's
next release moves to @abaplint/transpiler-cli and @abaplint/runtime
2.14.2 (abap2UI5/abap2UI5#2864), which writes no flat output folder any
more: the transpile's input goes into output/project/, each library
into a folder of its own (output/open-abap-core/,
output/express-icf-shim/), and a class imports another origin's
module by a relative path into that folder
(await import("../open-abap-core/cx_root.clas.mjs")). The build's
rewriteImports only knew ./<file> and pointed it at
@abap2ui5/node-runtime/output/<file>, which no longer exists - every
dev app would have failed to build. The build now tells the two layouts
apart (outputLayout): a flat release (1.146.0 and earlier) builds as
before; a folder-layout one has its dev apps copied by the package's own
setup/own-apps.mjs, run as a child, which points every import at
@abap2ui5/node-runtime/output/<folder>/<file> - the package indexes
its own folders, so mcp-server keeps no copy of that logic. apps/
stays flat (the runner's ./project/ test modules are rewritten beside
it), a transpiler whose layout is not the package's fails the build,
and the check for a stray import now also catches a ../<folder>/ one,
on whatever wrote apps/. app_list finds class modules one folder
down (the package's output/project/, a checkout's node/output/project/),
and the prebuilt download refuses a backend-<version>.tar.gz whose
layout is not the one the checkout's locked transpiler writes (a
checkout past the switch whose version's release predates it) - auto
builds the framework itself instead. The integration tests run on a
local build of the package with A2UI5_MCP_TEST_RUNTIME_TGZ, and pin
the property that matters: every import in apps/ resolves to a dev
module or the package's own file, and a CATCH cx_root in the app's
unit test catches a runtime exception. Proven on the package built from
abap2UI5/abap2UI5#2864 (transpiler 2.14.2) and on the published 1.146.0
(2.13.93).
-
read_example and read_app count a file's lines right. Every
abapGit file ends with a line break, and the line after it was counted as
a line of its own: lines was one too many, and a from_line past the
end answered an empty source under a pastEnd that promised the file's
last line. The break after the last line now opens no line; the page that
ends the file carries it, so the pages still join to the exact text.
-
docs_search refuses a blank query, and a pitfalls miss names its
catalogue. A query of spaces searched for no term and answered a hint
about its terms; it is now the same error as a missing query. A
pitfalls query that matched nothing said "nothing in the all
catalogue"; it says "either catalogue" for the default area.
-
A failed warm lint no longer leaves stale findings behind. The lint
worker applies a deploy's changes to its registry one file at a time; when
one of them threw, the changes before it stayed in the registry while the
worker's record of the sandbox did not move, so a file changed back to
what that record held was never updated again and its old findings came
back on every later lint. A failed update now drops the registry, and the
next lint parses afresh (the failed one runs the CLI, as before).
-
The answer guard shrinks in near-linear time. guardAnswer (and
fitObject) halved the largest array or string of an over-budget JSON
answer in a loop that re-serialised the whole value twice per cut, and a
string already trimmed as far as it goes was picked again on every step
until the 10,000-step guard: 2.5 s of CPU for a 474 KB answer of 480 rule
explanations, and ~230 s for the property test in test/paging.test.mjs,
which made it most of CI's test job. Every node is now sized once, the
sizes are updated by the difference a cut makes, a heap names the largest,
and a cut that changes nothing ends the loop: the same 474 KB answer takes
4 ms, the property test (still its 400 seeded cases, and now asserting that
a shrunk JSON block parses) 3.5 s. The cuts are exactly the ones made
before - same order, same notes, never over the budget - which a new test
pins against the literal rule over seeded values at every budget a cut
passes through.
-
The texts an agent reads say what the answers do. A drift sweep of
the tool and schema descriptions, the prompts, the resources, the README,
docs/agent-snapshot.md, SECURITY.md and server.json against the code:
examples, docs_search, pitfalls, read_app and read_example
declared their paging arguments (offset, from_line) without their
descriptions saying they page, or naming pastEnd; validate_view and
fix_view never named rulesCut, fixedCut or remainingCut, nor
run_app errorsCut, screenshotCut (at 4096 px) or
screenshotNotSaved, screenshot_view notAttached, interact_app
notPerformed, run_unit_tests its per-object count; build_backend
documented neither mode npm nor that a client's cancel stops it nor
devAppsNotBuilt; migrate_report said nothing of the @KERNEL
refusal, the dynamic-WHERE warning or the files it leaves out of a large
answer; add_agent_setup nothing of the directories it refuses or the
links it skips; scaffold_app nothing of its pages; the app tools
nothing of how a large snapshot is fitted (and its note promised that
max_rows pages a cut table - it does not; rowCount and app_act reach
every row); the build prompt told the agent to read app_guide and
pitfalls whole in one call, which pages; capabilities answers
entries for a status alone too; the README named the screenshot file
<class>.png and said booting needs no network without the corpus;
SECURITY.md described the child environment by category and left out
NODE_ENV, DEBUG, NODE_ICU_DATA, NODE_USE_ENV_PROXY and
LD_LIBRARY_PATH; the boot timeouts' bounds were nowhere. Three tests
keep it so: every paging argument a schema declares is named in its
description, every ...Cut field the code answers is in AGENTS.md's
vocabulary and in a description, and SECURITY.md names exactly the
allowlisted variables.
-
A resource read fits the client too. resources/read handed the
documents over whole with no guard, and two of them are past what a
client shows of one read - abap2ui5://pitfalls/abap (105,000
characters) and abap2ui5://capabilities (62,000) - so a client that
caps resources showed nothing. A document past the answer budget is now
cut at a section heading and ends in a note naming the call that reads on
from the first section left out (pitfalls { area: "abap", offset: 4 },
counted by the tool's own slicer); a JSON resource is shrunk as JSON; the
MCP Apps screen, HTML a host renders, is never cut. The descriptions of
the document resources say so.
-
run_unit_tests { class_name: "" } is refused instead of running
everything. A blank name was read as no name, and on a framework
checkout that runs the whole transpiled tree - minutes - for what was a
name gone missing; it is refused now, naming what leaving the argument
out does (optionalName in lib/args.mjs). And class_names: null is
absent, as null is for every other argument, where it was refused as "not
an array" (an empty list stays refused).
-
examples survives a catalogue field of an unexpected type. A
catalogue.json entry with a numeric title (or an object summary,
keywords that are a number) threw out of .trim() in catalogueEntries,
and the repository's whole JSON was lost with it - the search fell back to
SAMPLES.md, or failed outright over an injected catalogue. Every field
is now coerced (a number or boolean as written) or dropped (an object, an
array where text belongs); an entry whose class is an object or blank is
skipped.
-
add_agent_setup refuses the home directory and the forbidden
directories on Windows whatever their spelling. The checks compared
paths case-sensitively, and on Windows neither the resolved path nor
os.homedir() comes back in one canonical case - C:\USERS\ME was "not
the home directory", a differently cased path into this server's
installation not inside it. Under the win32 path semantics both sides are
folded now (pathWithin), the same for the writes' own containment
checks; POSIX stays case-sensitive. Tested with path.win32 over a fake
Windows disk, so it is pinned on every machine.
-
system_status no longer reads a logon page as a working logon. Any
200 was "accepted the logon" - and an ICF node with a form or SSO logon
answers Basic credentials it does not take with its HTML logon page, a
200. The answer is ok now only when the abap2UI5 start page answers
(the z2ui5 component the framework's _http_get shell declares); a
logon page is a problem that says the endpoint must accept Basic
authentication, any other page one that asks whether the URL is the ICF
service's path.
-
No tool answer can pass the client's size limit any more - a backstop at
the one place answers are serialised. Every tool already fits its own
answer (the paging vocabulary), but a composed or unbounded one could still
add up past the cap, and a client handed more than its cap shows the agent
NOTHING. guardAnswer (lib/budget.mjs) now wraps every tool result at the
single dispatch point: an over-budget answer has its largest array fields
and longest strings trimmed the documented way (a JSON block stays JSON; a
__answerGuardCut note says it happened and how to page the rest), so no
answer leaves over the budget whatever a per-tool fitter missed. Per-tool
fitting stays the primary mechanism; this is the last line. A seeded
property test drives 400 oversized results through it. Two spots that could
reach the edge are also fixed at the source: validate_view (and so
verify_app) caps explain: true's rule explanations (fitRules) - a
view tripping a dozen distinct rules carried them all, and rules alone
could pass the budget while only the findings beside it were ever shrunk
(rulesCut now names what was left out, the most severe findings' rules
kept first) - and migrate_report { deploy: true } fits its composed
deploy/build/start stages together (fitObject). The guard wraps the
WHOLE dispatch - server.mjs registers one tools/call handler,
guardAnswer(await dispatchToolCall(...)), so the error answer of a
throw (whose message can carry a build's output) passes it too - and it
holds an answer's pictures to IMAGE_BUDGET as well (8 MB of base64,
formerly screenshot_view's alone). test/answer-guard.test.mjs makes
the guarantee fail-able: it checks the single registration in the source,
and sweeps every tool name (sandbox and system mode, an unknown name, a
bad argument) over stdio under a test-only loader hook that marks what
guardAnswer returned - an answer that bypassed it carries no mark, and
every content block is measured against its budget.
-
Security: a hostile checkout can no longer steer a read or a write
outside it through a symbolic link. The path checks (safeRelPath, the
sandbox name gate) stopped a .. or an absolute path in a STRING; they did
not stop a symbolic link in the checkout itself, and a checkout is
untrusted content (a sample cloned from GitHub, an app under deploy).
read_example reading a "sample" that is a link to a file outside the
repository, docs_search walking a page directory symlinked out of the
docs tree (or into a cycle), and deploy_app / migrate_report's deploy /
read_app writing or reading through a link planted in the dev sandbox all
now refuse when the path - every symlink on it resolved - leaves the
intended root (resolvedInside, now in lib/contain.mjs). A sweep of
every file read and write found the rest, and they go through the same
check now: the guide (app_guide and its resources), the interface
(api_reference), both pitfall catalogues, the capability map, the
porting brief (generation_rules), the sample catalogues examples
searches, the docs tree's root itself (a docs -> ~ link walked the home
directory), every template file scaffold_app serves (template.json may
also no longer list ../ paths - both are answered as refused) and
add_agent_setup copies into a project (and the check-pin.mjs it
imports), the support classes migrate_report { deploy: true } copies,
the sandbox the npm build hands the transpiler, the corpus copies the
incremental build makes and the lint config it writes into a checkout.
A DANGLING link counted as inside (its parent resolved inside) and a
write through it creates its target wherever it points - it is outside
now; and the sandbox and project writes open with O_NOFOLLOW
(writeNoFollow), so a link that appears after the check fails the
write instead of redirecting it. test/security.test.mjs pins each.
-
Security hardening: what transpiled ABAP can reach. open-abap's
@KERNEL escape (WRITE '@KERNEL <js>'.) turns a literal into JavaScript
once transpiled, and that code runs in the backend and unit-test children
with process.env in reach. Running the app is trusted code (SECURITY.md);
this is defence in depth, not a substitute for not deploying untrusted
ABAP. Two changes:
- The children no longer inherit this server's environment: they get an
ALLOWLIST (
appChildEnv in lib/runtime.mjs) - PATH, the home and
temp directories, TZ and the locale, NODE_OPTIONS, the CA/TLS and
proxy variables, NODE_ENV/DEBUG, the Windows system variables -
plus PORT and the loopback HOST, never ALLOWED_HOSTS. A denylist
of the secrets this server knows (the GitHub token, the system-mode
credentials) would still hand over everything else a shell exports -
cloud keys, registry tokens. Enumerated from what the children read
(the npm host, the framework's express.mjs, the runtime and express -
NODE_ENV and DEBUG are all of it - and Node itself; the Windows
names from Node's documentation), and measured: deploy_app, build_backend, app_start
and run_unit_tests ran end to end on the npm backend from a parent
environment of decoys (GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY,
NPM_TOKEN, DATABASE_URL, the SAP password, ALLOWED_HOSTS=*) with
an app and a test class that print Object.keys(process.env) through
@KERNEL - both children saw only allowlisted names - and the
framework checkout's backend booted and served an app the same way.
The build, lint, git and npm children run no app code and keep the
full environment. test/backend.test.mjs pins it, the spawned child
included.
migrate_report refuses a report that contains the escape (comments
aside), and any converter output that does, before anything is
converted or written - with the place and the remedy. On an SAP system
the statement writes a line of text, so no classic report has a reason
to carry it, and report2cloud copies a refused statement into the
partial draft as it is (a local class's WRITE), where a later
deploy would run it; flagging it instead would leave the live escape
in the files of the answer. open-abap has no other source-level escape
(CALL '<kernel>' dispatches to a fixed list, GENERATE SUBROUTINE POOL and INSERT REPORT throw) - but @abaplint/runtime evaluates a
dynamic LOOP AT ... WHERE (<condition>) with eval(), so a condition
built from a selection-screen field is code injection on the backend.
That is ordinary ABAP: converted, and flagged in a new warnings list.
test/migrate.test.mjs pins both.
-
scope_of says when the OpenUI5 checkout is what is missing.
Without the checkout samples-controls' scripts/scope-of.mjs reads
the JSDoc from (OPENUI5_SRC, else fork-openui5 beside the corpus),
every OpenUI5 entity came back "UNRESOLVED (no source .js found - check
the entity name / fork checkout)", sap.m.Wizard included - an answer
that reads like a misspelt name. It now says the checkout is not there,
where it belongs, and that validate_view answers the 1.71 question for
a view without it.
-
run_app and interact_app say when UI5 itself did not load. The
UI5 runtime comes from the corpus' @openui5 packages or the CDN, and a
failed load of it was in no report: with sdk.openui5.org out of reach
and no local packages, the answer was "boot: page.waitForFunction:
Timeout 60000ms exceeded" and a blank picture - nothing an agent could
tell from a broken app - and an app that booted without its theme was
ok with an unstyled screenshot. A UI5 bootstrap that did not load is
now an error naming the URL, the reason and the remedy (and that
app_start needs no browser); a booted app whose theme or a library
did not load carries a ui5 note.
-
validate_view's hint is about the findings that are left. Any
hint made it explain that "an event without a handler is intended when
the roundtrip alone is the point" - beside an unused namespace or a
spelled-out get_event_arg( 1 ), neither of them about events (bench
task 13's reference, samples-controls 533) - and it never said that
fix_view clears the findings marked fixable. It names the event
case only when an event finding is there, and fix_view when a listed
finding is fixable.
-
app_act says what to pass for a control id only the browser knows.
A row button without an id of its own that opens a popover next to it
(${$source>/id}, the shape of samples 052 and bench task 13) was
refused with "cannot be read here - pass it in args[0]", and nothing
said what value that could be: the id is a clone id UI5 generates. The
refusal now says so and that any id will do for a backend that only
anchors a popover to it - this client places no popover. In the vendored
lib/appclient.mjs: the VS Code extension and abap2UI5/protocol pick it
up with their next re-vendor.
-
examples finds a sample by the controls its view builds. The
search read only the catalogue's own words - what a sample is filed
under and the keywords somebody chose - so sap.m.Dialog found 7 of the
33 samples-controls ports that build one, sap.m.SelectDialog 2 of 8,
sap.m.Table 23 of 62. samples and samples-controls commit
catalogue-derived.json beside the catalogue: every control type each
view builds. A query now matches those too, ranked below every match in
the catalogue's words (whose order is the one without the file), and a
hit that needed them names the controls it was found by under builds;
foundByBuilds counts those hits - a generic word like button matches
135 more samples that merely build one. The GitHub mirror carries the file when the repository has it
and is never refused for its absence; without it the search is the old one.
-
abap2UI5-bench no longer counts the app's own method as a client call
(bench/, not part of the package). The expect check's calls
matched any ->name(, and the canonical template names its helpers
like the client methods they wrap - so me->popup_display( ) passed
"calls: popup_display" for a solution that never called
client->popup_display (a dialog sent through popover_display
graded PASS). A call on me no longer counts; the new broken variant
own-method-not-client pins it, and every reference still passes.
-
A first build_backend on a checkout builds the deployed apps too.
With an abap2UI5 checkout and no prior build, mode auto downloaded
the prebuilt backend - the framework alone - and answered built with
next: run_app; the app deployed before it was in none of it, so
app_start answered HTTP 500 "does not exist", app_list was empty
and verify_app failed its boot stage on every first run. auto now
goes on into the incremental build of the deployed apps in the same call
(mode: prebuilt+incremental); an explicit prebuilt or transpile
still builds the framework alone and names the apps it left out
(devAppsNotBuilt, and a next that says how to add them).
-
docs_search counts every matching page. matches was the number
of pages it returned - 10 for a query 37 pages answer - so a cut answer
read as the whole site's, and nothing said where the rest was. matches
counts every hit now, returned the page, and more names the new
offset that continues it.
-
read_example pages a long sample by lines. It answered the file
whole, and samples-controls' largest ports are 84,000 characters - past
what a client accepts, so the agent read none of it. A long sample comes
in pages of whole lines now (page, nextPage, from_line), measured
as the answer is written, like read_app. A from_line past the end -
in either tool - was clamped to the last line without a word, usually
the empty line after the final newline, and read as an empty file; the
last line still comes back, with pastEnd naming the line count.
-
An npm build that cannot write its files fails with a reason. A
workspace whose open-abap-core/ or sandbox the build could not write
(a file where the directory belongs, a read-only or full disk) threw out
of the build: build_backend answered a bare ENOTDIR and build_log
kept the previous build's record. The fetch and the transpile answer a
sentence now, and the failed build is the one build_log shows.
-
An action list that is no list is refused with a sentence. A
response whose S_ACTION.T_SYSTEM or T_CUSTOM was an object or a
number made app_start / app_act answer "object is not iterable" -
applyResponse threw a TypeError. The client now refuses such a
response naming the list, and applyResponse folds it as an empty list.
Both are in the vendored modules (lib/appclient.mjs,
lib/snapshot.mjs): the VS Code extension and abap2UI5/protocol pick
the change up with their next re-vendor.
-
A build_backend that joined the running build can be cancelled, and
reports progress. A second call of the same mode joins the build in
flight, and its own cancel was ignored: the request stayed open until
the build ended (tens of minutes for a full one). It stops waiting now
and answers as cancelled; the build goes on for the call that started
it. And a joined call with a progressToken heard no progress
notification until the build ended - it follows the build's lines now,
until the build ends or the call is cancelled.
-
app_guide pages by chapter. The default call - the one its
description tells an agent to make first - answered the whole guide,
48,000 characters of the 60,000 an answer may carry, and the guide grows
upstream. Chapters that fit come back, more names the offset to
continue at, and limit caps the chapters, like capabilities and
api_reference; a chapter is never cut.
-
run_app and interact_app list a repeated error once. A page that
throws from a timer, or a poll the backend answers 500, pushed the same
line into errors for every occurrence - a 100 ms interval over a 60 s
boot is 600 lines, 180,000 characters, and the client showed the agent
nothing. A repeated message is listed once with its count ((600 times)), at most 20 distinct ones are listed and errorsCut counts the
rest; ok still counts every error.
-
validate_view and fix_view fit a view with hundreds of findings.
600 broken buttons answered 538,000 and 584,000 characters, and the
client showed the agent none of them. The findings are listed most
severe first, in source order within a severity (they came in the order
the gates produced them - a warning on line 140, a hint on 108, a
warning on 26), and the ones that fit are the head of that list;
counts stays whole and findingsCut / remainingCut say how many
were left out; fix_view's corrected source and validate_view's rule
explanations (explain: true, about 1,000 characters a rule) always go
whole, and the findings get the room that is left. verify_app, which
composes the tools' answers, fits its stages together too: a passed
validate stage gives up findings first, then the unit stage counts its
tests per object (a few hundred hints and tests were ~100,000
characters).
-
build_log, build_backend and read_app answers fit. build_log
answered tail lines whatever their length (2,000 transpiler lines:
464,000 characters) - the last lines that fit come back now, a line past
4,000 characters shortened, and cut names the call that reads the
rest. A failed build_backend put its 30-line tail into the answer
unshortened (1.5 million characters for a build that prints a bundle per
line). read_app paged by raw characters while the answer escapes every
quote: a class of JSON templates went out at 73,000 characters.
-
interact_app refuses a step it would perform otherwise than asked.
The tool's arguments are type-checked, its steps were not: { comit: false } and { commit: "false" } committed the fill anyway and
{ ms: true } waited 1 ms, each reported as performed. A step's unknown
field, a commit that is no boolean and an ms that is no number are
refused naming the step.
-
setup_status finds git, tar, npm and npx without which. It
spawned which (where on Windows) once per program, with no timeout,
and on an image without which reported every program missing. The PATH
is scanned in-process now, with PATHEXT on Windows.
-
migrate_report { deploy: true } installs the template's abaplint.
It runs build_backend, whose npm install reads app-template's
@abaplint/cli pin, but the mirror is hydrated per tool the client
called - on a machine without the template checkout the build fell back
to ABAPLINT_CLI_FALLBACK. migrate_report hydrates the template like
build_backend and verify_app do.
-
run_app and interact_app keep the 50 newest pictures, not every
one: each call wrote a full-page PNG of its own name and nothing removed
them. Only files of the server's own name pattern are removed, so a
directory A2UI5_MCP_SCREENSHOT_DIR names keeps the user's files.
-
A failed boot setup no longer leaks a browser context. A throw of
newPage, of the benign-rules import or of page.route in run_app /
interact_app left the context open for the life of the server and a
listener on the request's signal; both are released now.
-
A failed mirror download is not retried on every call. Without a
checkout and with GitHub unreachable, every call of a mirrored tool waited
out the 20 s fetch timeout again (examples, three mirrors, a minute).
A failure is now the answer for three minutes - the stale copy when there
is one, the reason in the missing-checkout message otherwise, with how
long ago it was tried and when it is tried again.
-
A client's cancel reaches the app tools' roundtrip. app_start and
app_act (sandbox and system mode) waited out the client's 120 s
timeout after notifications/cancelled, holding the session's queue the
whole time. The request's signal is combined with the timeout now
(start/act take a signal option, so the vendored client keeps its
defaults): the roundtrip is aborted, the act refused as cancelled and its
edits taken back.
-
A port another process holds fails the backend start instead of
handing the app tools to that process. Under express 5 the npm host's
app.listen() called back with the bind error too: the host printed
"Listening on", exited 0 a moment later, and startBackend's port wait
was answered by whoever held the port - app_start and run_app then
talked to a foreign server. The host listens on a plain http.Server
now and exits 1 with "port N on 127.0.0.1 is in use by another process"
(also over a release whose own serve() has the bug). And on both
backends a child that exits right after "Listening on" fails the start:
the port's owner answered the port wait before the exit was seen, and
two starts in three still reported the backend running - the child now
has to stay up half a second past its "Listening on" (the start ends
early when it exits).
-
System mode never logs the password of a refused URL. With
credentials in A2UI5_MCP_SYSTEM_URL (refused) or a URL that does not
parse, the "ready in SYSTEM MODE" notification showed the raw URL, user
and password included. It shows a masked URL now, masked up to the LAST
@ - https://alice:pa@ss@host used to keep ss - and an @ before
the query is refused as credentials (https://alice:12/ss@host parsed
as a host alice and was shown as the endpoint).
-
run_unit_tests no longer throws on a long passing run of one class,
and its answer fits. Past the budget it dropped tests and then read
tests.length (a TypeError); and it measured compact JSON while the
answer is indented, so 600 tests went out at 68,000 characters. Measured
as written now (sizeOf); migrate_report's cuts the same way.
-
capabilities and api_reference are paged like examples:
offset and limit, returned, a more that names the next offset,
every entry whole. A broad query answered ~61,000 and ~75,000
characters. api_reference pages its compact list (no query) the same
way, by entry, counting entries, and takes kind in the singular
(method, constant, type), which it refused. Pages are measured at
the depth their entries sit in the answer, examples and pitfalls
too: a full examples page came out ~2% over the budget.
-
ALLOWED_HOSTS from the user's shell no longer reaches the backend.
The framework's express.mjs reads ALLOWED_HOSTS=* as "answer any Host
and any Origin", which switched its DNS-rebinding guard off for the dev
backend.
-
The checkout backend, the corpus' e2e build, scope_of and the checkout's
unit-test runner run on this server's own node (process.execPath), as
the npm backend already did, not on whichever node the PATH finds first.
-
The npm host no longer mounts the release's compression twice
(createApp({ compression: false }) behind its own compress); the
responses are byte for byte the same.
-
Evicting an app session leaves a newer one with the same draft id
reachable. Sessions started from one screen share its id (the
playground's Pilot starts one per change of the reader's typing), and the
eviction of the oldest made the newest an "unknown session".
-
Adaptive Cards: app text is never Markdown. The renderer is
re-vendored from abap2UI5/protocol (with its shared renderers/common/
halves and the portable profile): a model value or a toast like
[verify your account](https://...) was a live link in Teams and Copilot;
a text with Markdown syntax is a TextRun now. Its htmlToText (a
message box's details) is linear.
-
System mode: the app tools against a real SAP system. With
A2UI5_MCP_SYSTEM_URL (the launch URL, {class} as a query parameter),
A2UI5_MCP_SYSTEM_USER and A2UI5_MCP_SYSTEM_PASSWORD - or
A2UI5_MCP_SYSTEM_PASSWORD_CMD, a command that prints it (a keychain
lookup) - the server offers system_status, app_list (an ADT class-name
search), app_start, app_describe and app_act against that system
instead of the sandbox loop, logged on with Basic authentication: what the
VS Code extension's system server does for its own window, for Claude
Desktop, Claude Code, Cursor and any other stdio client. Same snapshot,
arguments and refusals as the sandbox's app tools. A rejected logon is
sent once and then refused without a request, so an agent's retries cannot
lock the user. A2UI5_MCP_SYSTEM_INSECURE_TLS=1 accepts a certificate that
cannot be verified. Without the variable nothing changes.
-
The app screen in the chat (MCP Apps). app_start, app_describe and
app_act name the new UI resource ui://abap2ui5/app-screen
(text/html;profile=mcp-app, SEP-1865 stable 2026-01-26) when the client
advertises the io.modelcontextprotocol/ui extension - A2UI5_MCP_UI=on|off
overrides. A host that renders MCP Apps shows the snapshot as a page the
user can operate: fields, buttons, row actions, editable cells, messages.
What the user does goes back as app_act calls through the host (validated
like the agent's own) and is reported to the model with
ui/update-model-context. The page is one self-contained HTML document -
no external URL, no network - so it runs under the spec's default CSP.
-
Adaptive Cards. format: "adaptive-card" on the three app tools (or
A2UI5_MCP_APP_FORMAT=adaptive-card) adds the screen as an Adaptive Card
1.5 - an embedded resource application/vnd.microsoft.card.adaptive - for
Copilot/Teams-style hosts; off by default. Its submit payloads map to
app_act arguments (docs/agent-snapshot.md). The renderer is
abap2UI5/protocol's, vendored under lib/vendor/adaptive-cards with a
source record and a sha256 drift test (scripts/vendor-adaptive-cards.mjs).
-
createAppClient gains screen(session): a copy of a session's folded
state, for renderers other than the snapshot (additive; the vendored
copies elsewhere keep working unchanged).
-
System mode: a wrong password reaches the system once under parallel
calls too. Parallel tool calls all passed the breaker before the first
401 came back, so a wrong password was sent once per call (SAP locks a
user after a few), and the password command ran once per call. Until the
system has accepted the logon, requests go one at a time, and the
command's answer is shared. system_status and app_list give up after
30 s instead of waiting for good, a redirect names its target (a SAML
logon page), a malformed % in A2UI5_MCP_SYSTEM_URL no longer stops the
server from starting, and a URL that does not parse no longer shows the
user and password typed into it. A breaker refusal reads as itself, not as
"the backend did not answer".
-
An act queued behind one in flight runs only where it still names what
its caller saw. Two app_act { event: "a1" } at once fired the first
screen's a1 and then whatever a1 was on the screen that answered it
(NEXT, then DELETE_ALL - for real in system mode). The queued act is
refused, sending nothing, when its action or fields name something else
on the new screen.
-
A backend start during build_backend is refused. app_start,
run_app or backend start booted the output from before the build,
and the build then reported built while that process went on serving
the old classes.
-
The app tools' answers fit the client. A 120 KB TextArea or a table
of 200 wide rows went over the result cap by default. Long values are
cut (and read-only in that answer), then rows dropped from the end, and a
text beside the snapshot says what was cut. With format: "adaptive-card" the card counts together with the snapshot.
-
interact_app waits for each action's roundtrip. The frontend drops
an event fired while a roundtrip runs, and its busy overlay shows only
after a second: two quick clicks lost the second, and a click right after
a fill was dropped - each reported as performed.
-
backend stop stops a start in flight. It answered "not running"
while the start went on to listen, and a build that stopped the backend
while one started reported built while the old process served on.
-
Pictures are bounded. run_app / interact_app cut a page taller
than 4096 px (and say so); screenshot_view leaves out a picture over
8000 px a side or past its image budget, named. Each call writes its own
PNG - two calls on one class reported each other's picture.
-
Answers that grew without bound are paged or cut: a lint's findings
(the first 100, counted per rule - and the whole sandbox's JSON is read
in full: one app with many findings made every later lint a parse
failure), read_app (pages of whole lines, from_line), a full
run_unit_tests (counted per object), migrate_report's files.
-
The build refuses a dev object named like a framework class - for
every writer: deploy_app could not check before a release was
installed, and migrate_report did not check at all.
-
An argument a tool does not declare is refused. app_act { value: {...}, event } fired the event without the edits. max_rows: true
(or false, [], " ") is refused instead of read as 1 or 0, and an
error repeats at most 80 characters of an argument.
-
App sessions are evicted least recently used first. The session
started first went first, even while it was the one in use, and a session
evicted while an act was in flight came back unlisted.
-
An event name without row fires the screen action of that name
before a row action of the same name (a "delete selected" button after a
table with a DELETE per row).
-
The Adaptive Card shows the snapshot's rows. It rendered every row of a
table - 5,000 rows were a card of ~1 MB in the same result as the
snapshot - and Save on the untouched card wrote back every row the
snapshot did not show. A card over the answer budget is left out with the
reason.
-
An untouched MCP Apps form sends nothing. A choice whose value matched
no key showed - and sent - its first option, and a check box bound to
null, '' or 'X' counted as changed. Message box choices with
parentheses stay whole. Untouched now means "as rendered": a textarea
with CRLF or a leading newline, a text with a newline and a
multi-select in another order were written back on every press too.
-
The MCP Apps screen keeps what the user typed and the focus across a
failed act and while one runs; it reads the current screen when its
session moved on (Refresh failed for good once the agent had acted);
a button that needs a value only a browser computes is disabled instead
of failing every time; the reported height shrinks again; Refresh keeps
the tool input's max_rows.
-
The snapshot survives � in a view and unclosed < in model
markup. The first threw the whole snapshot (after the act had reached
the backend), the second took seconds for 80k characters.
-
A deploy lint reports the deployed class's findings only. They were
picked from the repository-wide abaplint run by a substring of the path,
so z_app was handed the findings of zz_app (any class whose name ends
in its own) and a clean class failed deploy_app - and verify_app
stopped at deploy. Matched on the file name now (issuesOfClass).
-
A backend that cannot be spawned fails its start at once. With no
node on the PATH the server was started with (a desktop client's
minimal one), the backend child emitted only error: an uncaught
exception, and run_app / app_start / backend start waited 30 s to
report "did not start" with an empty output. The start now fails with
the spawn error right away.
-
read_example re-fetches a mirrored sample once it is a day old. A
file fetched on demand into the GitHub mirror was served from the cache
whenever the mirror's marker was fresh - and the marker is refreshed by
every knowledge tool once a day, the on-demand files never - so a sample
read once stayed at that first copy for good. The file's own age counts
now (A2UI5_MCP_REMOTE_TTL_MS).
-
server.json lists POPUPS_HOME. migrate_report { deploy: true }
reads it (through an injectable env parameter), and
scripts/check-server-json.mjs only looked for process.env.X, so the
variable was missing from the registry listing without the gate noticing.
The scan reads env.X too.
-
setup.sh reuses a corpus checked out as abap2UI5-api. Its list of
pre-rename directory names left that one out (lib/repo-dirs.json has it),
so such a checkout was cloned a second time as samples-controls. A test
now holds the script's lists against the JSON.
-
remove_app removes every file of the class. It removed the source,
the sidecar and the test include, and left the local-class includes
(locals_imp, locals_def, macros) that migrate_report { deploy: true } writes - the class disappeared from the deployed-apps list while
its includes stayed in the next build's input.
-
The incremental build no longer clones open-abap-core synchronously.
The git clone (and the corpus' patch script) ran through
execFileSync: the stdio server answered nothing for the length of the
clone, a cancel or the shutdown could not stop it, and a failed clone
threw out of build_backend without a build_log record. Both steps are
spawned like every other child now - cancellable, killed with their tree,
reported and logged; a failed step takes the half clone with it.
-
Progress notifications keep their one-per-second throttle. A child's
output was handed to the line callback through forEach, which passes the
line's index as a second argument - and the server's progress reporter
reads its second parameter as force. Every line after the first of an
output chunk bypassed the throttle, so a chatty lint or unit-test run sent
one notifications/progress per line.
-
A child's output reaches the build log and the progress messages in
whole lines. Lines were cut at the pipe's chunk boundaries (one line in
two halves, a UTF-8 character as two replacement characters); each stream
is decoded as UTF-8 and buffered to its newline now.
-
The backend's stderr is no longer kept for its whole life. The start
collects the child's output to report a failed start, and went on
appending every line the backend wrote to stderr after it was listening -
memory the server never released during a session.
-
validate_view / fix_view check allow. A bare string instead of
the array the schema names was passed to the linter as it was: beside a
project config its characters became the allow list (the allowance
silently ignored), without one the gate failed on allow.map is not a function. Refused by name now, like every other list argument.
-
examples { area: "samples" } finds the samples again. The area was
read off a src/01 path prefix, and abap2UI5/samples flattened src/01
and src/00 into one src/ package on 2026-09-22 - so every sample of a
current checkout was filed as experimental-or-test, and the filter the
description recommends answered nothing. src/00 decides now; an older
checkout reads as before.
-
Boolean and object arguments are checked against the schema. The
handlers read booleans as === true / === false, so a client that sent
"true" got the default without a word: add_agent_setup { dry_run: "true" } wrote into the project it was asked only to plan for,
migrate_report { deploy: "true" } deployed nothing, verify_app { boot: "false" } booted. screenshot_view's model as a string was spread into
the derived model one character per key. Refused by name now, like a
string argument that is not a string.
-
run_app / interact_app recover from a Chromium that went away. The
browser was kept for the server's whole life: one that crashed or was
killed was handed out again, and every later call failed on "browser has
been closed" until the server was restarted; a launch that failed (no
Chromium installed yet) stayed the answer after the install. A
disconnected browser and a failed launch now clear the slot, and the next
call launches afresh.
-
The npm backend's install never rejects, as documented. A workspace
the install could not write into (read-only, a file where the directory
belongs, a full disk) threw out of ensureRuntime: build_backend ended
without a build_log record, deploy_app's lint answered a bare
ENOTDIR, and every caller queued behind the failed install was rejected
with it. It is the install's reason now, naming the directory.
-
docs_search survives a dangling symbolic link in the docs tree. The
walk statted every entry, and one link to a file that is not there (a
half-finished pull, a moved page) threw ENOENT out of it - every search
failed. Such an entry is skipped now.
-
A mirror refresh takes out what the repository no longer lists. The
daily refresh of a GitHub mirror wrote the new file list over the old one
and removed nothing, so a docs page renamed or deleted upstream stayed in
the mirror and docs_search - which walks the mirror's tree - kept
answering with it (and a URL that 404s). Files the previous refresh listed
and this one does not are removed now; a sample read on demand is not one
of them.
-
Two run_unit_tests calls at once run their own classes. The
filtered copy of the runner was named after the selection alone
(index-mcp-selection.mjs for any set of classes), so two runs over
different sets wrote one file: the first answered with the second's tests,
and the first to finish deleted the file under the other. Each run writes
a copy of its own now (index-mcp-<class|selection>-<pid>-<n>.mjs).
-
npm test no longer writes into the user's build log. Four test files
ran builds without A2UI5_MCP_SCREENSHOT_DIR, so the fake build's
last-build.json landed in the real <tmp>/abap2ui5-mcp-screenshots - a
freshly started server's build_log then answered with a test's run as
the user's last build - and the cancel and progress tests fetched the
app-template mirror into the user's cache. Each test keeps both in its own
temp dir now; the runtime default is unchanged.
-
app_act fires the action it was asked for when its values renumber
the actions. Action ids follow the document order, and the act re-read
its action by id after applying values: a value that showed a control
in front of the action (a checkbox making a "Delete" button visible) made
{ values: { "/SHOW": true }, event: "SAVE" } send DELETE - an event
nobody asked for and no check had seen. The act now fires the action it
validated, found again on the new screen, and refuses when that is no
longer one action (as the abap2UI5 agent addon does).
-
An interact_app fill of a missing element fails within
action_timeout_ms. Before filling, the action looked whether the
located element is itself editable - with Playwright's default timeout,
so a fill of an id that is not on the page waited 30 s for it and then the
fill's own timeout on top (40 s at the default 10 s, whatever the
argument said). The look runs within the action's timeout now, and an
element that never appears fails the action there.
-
verify_app's progress only goes up. Each stage made its own
progress reporter for the one request, counting from zero, so the
notifications of one progressToken went 1, 3 (the deploy lint), then 1
again (the build) - the MCP spec says the value MUST increase with each
notification. The counter (and the one-per-second throttle) belongs to
the request now.
-
abap2ui5-unit no longer reports an unreached class as having no test
method. The generated runner stops at the first failing test, so a test
class after it in the runner's order printed nothing - and the summary
said "a test include, but the runner found no test method - is the local
class FOR TESTING?", sending the reader after a problem that is not there.
Such a class is notRun now (in --json too), naming the failure the run
stopped at.
-
abap2UI5-bench keeps the agent's transcript in whole characters
(bench/, not part of the package). Its child-process helper turned each
output chunk into a string on its own, so a character the pipe split
between two chunks - an em dash in the agent's text - became replacement
characters in transcript.jsonl, the result text and the grader's
output. The streams are decoded as UTF-8 across chunks now, and
verify.mjs checks it.
-
The server stops when its client stops reading. A client whose end
of the server's stdout was gone (EPIPE) while stdin stayed open left the
server in a loop: the failed write was an uncaught exception, the crash
report went out as a logging notification into the same broken pipe and
failed again - about 90% of a core and a growing pile of drain
listeners, for as long as stdin stayed open. A broken stdout now shuts
the server down the way a closed stdin does.
-
app_act's args schema declares its items. It was an array schema
without items - valid JSON Schema, and refused by clients that check a
tool's schema before offering it (VS Code: "array type must have items";
OpenAI-backed hosts: "array schema missing items"). items: {} - any
value, as before - and a test holds every array of every tool schema to it.
-
Resource and prompt errors carry the JSON-RPC codes the MCP spec
names. An unknown resource URI (or guide chapter) answered -32603, an
internal error of this server, where the spec says -32002 (resource not
found); an unknown prompt and a missing required prompt argument
answered -32603 too, where it says -32602 (invalid params) - as does a
chapter URI with broken percent-encoding, which answered a bare
"URI malformed". The messages are unchanged; a missing checkout stays
-32603.
-
A late drop of a dead warm renderer no longer closes its successor.
When two validate_view / screenshot_view calls shared a warm Chromium
and both saw it die, the first dropped it and the next call opened a
fresh one - and the second drop then closed that fresh renderer under the
call using it, which saw a dead browser in turn and fell back to the cold
path. A drop now names the renderer it saw die and leaves a newer one in
place.
-
A build_backend refused for a build in progress keeps the backend
running. The tool stopped the running backend before the build checked
for an in-flight build of another mode, so a call answered "build in
progress" had built nothing and still taken the app down. The backend is
now stopped once the build really starts (buildBackend's beforeBuild).
-
A migrate_report { deploy: true } the sandbox refuses writes nothing.
A class_name report2cloud accepts but the sandbox does not (a
namespaced /abc/cl_x, or one outside z/y) was refused only after
the report runtime's support classes had been copied into the sandbox,
so the refused deploy still changed the next build's input. Every name
is checked before the first file is written now.
-
screenshot_view refuses a zero-size viewport. sizes checked the
digits (\d{2,5}) but not their value, so 00x10 passed as a viewport
0 pixels wide - which Chromium's device metrics override reads as "no
override", a picture of its default window. An edge under 10 pixels is
refused by name now, like one over 4096.
-
validate_view renders cold when the warm renderer throws. A warm
Chromium that failed mid-call with a throw (a wedged page its dead
browser could not reload) was dropped, but the throw went on to the
"render gate could not start" fallback: the answer was the property
findings alone - ok: true for a view whose render errors were never
looked for - with a note to install @abap2ui5/linter-render. The call
is retried cold now, as it already was for a dead browser that did not
throw, and as screenshot_view does (warmThenCold in
lib/validate.mjs).
-
A shutdown during a backend start leaves no backend behind. The
backend child was only tracked once it printed "Listening on", so a
client that went away while backend start / run_app was booting it
had the server exit with the child untracked - and the child then
listened as an orphan holding the port for the next session. The
shutdown now also kills the child of a start in flight
(stopBackend({ starting: true })); backend stop and restart are
unchanged.
-
A binding's model key names its model in the agent snapshot. The
object syntax {path: '/A', model: 'other'} - UI5 binds it to the model
other - was read as /A of the default model: the field was listed
editable with the default model's value, a table bound that way got
editable cells, and an app_act value was written to /A (or
/T/0/N) of the app's own model, which the screen does not edit. It is
read like {other>/A} now (noted as bound to a named model), as the
abap2UI5 agent addon's snapshot already does; docs/agent-snapshot.md
says so. The copies the VS Code extension and abap2UI5/protocol vendor
pick it up with their next re-vendor.
-
An edit the old view never sent dies with it when the view is displayed
anew. app_act re-applied every unsent edit after each response, also
into a page or dialog the response displayed again - where the frontend
builds a new model with nothing pending. A value typed into the page
behind a popover (or while a roundtrip was in flight) overwrote what the
new page was sent, stayed pending and went out with its next event,
into another app's model too. Only a model push keeps unsent edits now
(rebuiltModels in lib/snapshot.mjs); docs/agent-snapshot.md says so.
-
A model push no longer makes up table rows for an unsent edit. The
re-apply of a pending edit created every missing step of its path, so a
cell edit of row 5 in a table the push shrank to two rows made it six rows
long, with empty rows the snapshot listed and the next delta sent as the
edited row. It is re-applied as JSONModel#setProperty does now - only
where its parent exists; the path stays pending, as in the frontend.
-
A backend error body padded with whitespace no longer stalls the
server. The refusal text trimmed its end with /\s+$/, which is
retried from every position of a whitespace run that is not at the end:
quadratic, 15 s of a blocked event loop for a body with 100k blanks and
100k newlines inside it. trimEnd() removes the same characters in
linear time.
-
The build-an-abap2ui5-app prompt names the keys app_act takes. It
told the agent to "fill fields by label", and app_act resolves a
values key by field id, model path or attribute name only - every value
keyed by its label was refused. The brief says so now.
-
A view cannot make app_act write into the server's prototypes.
setAt walked a model path through whatever each step answered, and a
plain object answers __proto__ with Object.prototype: a field bound to
{/__proto__/shell} - view XML is the backend's, a real system's when the
client is embedded - turned a value typed into it into
Object.prototype.shell for every object of the process (the card
renderer's way back wrote card payload keys the same way). A path through
__proto__, constructor or prototype is refused by app_act now and
never written by setAt; docs/agent-snapshot.md says so. The copies the
VS Code extension and abap2UI5/protocol vendor pick it up with their next
re-vendor.
-
An element named __proto__ no longer breaks the agent snapshot. The
control tables were looked up by plain index, so a table cell or list
item element named __proto__ (or __defineGetter__) found
Object.prototype and was read as a field spec without properties - a
TypeError, and app_start / app_describe / app_act failed for that
screen. Only the tables' own keys are read now.
-
app_start names the session it started when its values are
refused. The app was running and kept in the client, but the refusal
carried no session id, so it was out of reach and the agent's next
app_start opened a second one. The refusal ends "(the app is running:
session - app_describe shows it)" now, as the ABAP agent addon's
does.
-
A number field takes a number, not whatever Number() makes of a
value. app_act read a value for a field whose model holds a number
with Number(), so true went out as 1, "0x10" as 16, "1e3" as 1000,
and "Infinity" as null - the field's initial value, without a word.
A number or a decimal string is taken now, as the agent addon's
describe_arg reads it; anything else is refused naming the field.
-
app_act's row is a row index, not whatever Number() makes of
it. row: "", false or [] acted on row 0 and row: true on row 1
- a pick or a row action on a row nobody named.
row is a non-negative
integer now (a number, or a string of digits); anything else is refused.
-
Catalogue rows, doc headings and view wires parse in linear time.
Their patterns retried every split of a run of blanks between two \s*
(or a lazy group and \s*$): a SAMPLES.md row with 1000 blanks and no
sample link - a padded header row - blocked the server for 85 s in
examples (read from SAMPLES.md where no catalogue.json is), a heading line with 10k blanks took 0.4 s in
docs_search, and an .eB(...) attribute with a long run of blanks after
its parenthesis 14 s per 100k in the app tools. The rewrites match the
same lines and capture the same text; a test holds them to the old
patterns.
-
build_backend prebuilt, transpile and full run npm on Windows. The
framework checkout's npm ci (before serving a downloaded backend) and
its own build's npm ci / npm run downport / npm run auto_transpile
spawned npm without a shell. On Windows npm is npm.cmd, which spawn
does not find that way (ENOENT), so those modes failed at their first npm
step there. They go through the npm backend's spawnNpm now, as its own
installs always did; a test holds every npm spawn of the server to it.
-
A long app session no longer grows the server's memory with every
act. Each roundtrip answers a new draft id, and the client kept every
one a session ever had, to refuse it as "an earlier state" - about 1.5 MB
per 10,000 acts that a long-running server never gave back. The last 100
earlier ids are named as earlier states now; an older one is refused as
an unknown session, naming the open ones.
-
Catalogue rows and markdown links parse in linear time. Two patterns
were still quadratic in text from a mirrored repository: the SAMPLES.md
row tried the sample link after every end a text block could have, and
scanned to the next ) each time (0.4 s for a 50k row of "|A;
the markdown link pattern docs_search flattens snippets with (and
examples reads a row's docs links with) scanned to the next ] from
every [ (3 s for 50k of them). Both are hand-written scanners now that
match what the patterns matched - fuzzed against them, and identical on
the real catalogues and the docs site.
-
A framework checkout's backend listens on 127.0.0.1 only. Its
node/srv/express.mjs binds every interface unless HOST is set, and the
server started it with PORT alone: beside a checkout, every deployed app
(and the framework's own) could be run by anyone who reached port 3000 on
the network, while the npm backend has always bound loopback. The backend
is started with HOST=127.0.0.1 now, also over a HOST the user's
environment exports.
-
The README's timeouts and popups lookup match the code. The timeout
note left out A2UI5_MCP_UNIT_TIMEOUT_MS (the unit-test runner's 10 min),
and migrate_report's popups were said to be looked for in build/popups
before ../popups - the server takes .deps/popups, ../popups, then
build/popups. RELEASING.md's tarball list now names the abap2ui5-unit
script and action.yml it ships. A test holds the three to the code.
-
Security: the GitHub mirror's default base must be the user's own.
<tmp>/abap2ui5-mcp-remote has a fixed name under the temp dir - /tmp on
Linux, writable by every local user - and the server took whatever it
found there. Another user who created the directory first, or a symbolic
link of that name, decided what the knowledge tools served (the guide the
agent follows, a template.json whose files add_agent_setup writes into
the user's project), and the mirror's writes and the removals a refresh
makes followed the link into the user's own files. The default base is now
used only while it is a real directory owned by the user and writable by
nobody else: it is created 0700, one of the user's own that others can
read is narrowed to 0700, and anything else is refused with the reason and
A2UI5_MCP_REMOTE_DIR (which stays unchecked) as the way out.
-
Security: the default screenshot dir must be the user's own as well.
<tmp>/abap2ui5-mcp-screenshots holds the PNGs of run_app and
interact_app and the persisted build log, under the same fixed name in
the shared temp dir: another local user who created it first read every
screenshot of the user's apps and handed a restarted server's build_log
a last-build.json of their own, and a link of that name took the writes
wherever it led. The default is used only under the same rule as the
mirror's base now; otherwise the screenshot is still returned, not saved
(screenshotNotSaved says why), and no build log is written or read there.
-
app_act: an act whose values disable or hide its own action is
refused. The action was checked for enabled before the values were
applied, and fired after them without asking again: OPEN set to false
together with DEL pressed a Delete button with enabled="{/OPEN}" the
browser had just disabled. And values that hid the action left its id
with nothing behind it - the old snapshot's entry was fired anyway, a
control the screen no longer shows. Both are refused now before anything
is sent, with the values taken back; an action that only moved to
another id is still fired as itself. The agent addon refuses both the
same way.
-
app_act: the whole event parameter object is asked for in args.
${$parameters>/} and ${$parameters>} name the whole parameter object
of a row event, which holds the row as an item or a binding context - and
the client's internal markers for those ({"selectedItem":{"$item":0}, ...}, {"rowIndex":0,"rowContext":{"$ctx":0}}) went out as the event
argument, where the browser sends the controls marshalled. A value that
holds an item or a context anywhere is refused naming args[i] now, as
the agent addon does.
-
Security: the npm backend's host serves loopback requests only. It
listened on 127.0.0.1 and answered any Host and any Origin: a web page
the user opened could post events to the dev apps, and through DNS
rebinding (a name of its own resolving to 127.0.0.1) read the answers as a
same-origin page. lib/npm-host.mjs now answers only requests addressed to
127.0.0.1, localhost or [::1] and, when they carry an Origin, sent
from a page on one of those - run_app's Chromium and the app tools are;
anything else gets a 403.
-
The server reaches its own backend at 127.0.0.1, never at localhost.
The backend binds 127.0.0.1, but run_app's and interact_app's Chromium
opened http://localhost:<port>, the start waited for the port at Node's
default host (localhost) and the page's backend errors were picked out
by the name localhost. Where that name resolves to ::1 first, those
connections reached [::1]:<port> - which another local user can listen
on, serving the page and answering the start - or nothing at all. All of
them use BACKEND_HOST now (appStartUrl, waitPort, isBackendUrl,
backendBaseUrl); the npm host's Host/Origin guard accepts what the
Chromium sends from that address.
-
Downloads from GitHub are capped in size, not only in time. The
mirror's files, the docs tree listing and the framework's release list
were read whole, and the prebuilt backend archive written whole, within
their timeouts - a proxy or mirror answering with a huge or endless body
filled the memory or the temp disk first. Text answers are read up to 8 MB
(TEXT_MAX_BYTES, readCappedText; the largest real one is 0.34 MB),
the archive up to 200 MB (PREBUILT_MAX_BYTES; 2.7 MB for 1.146.0): one
that declares more is refused unread, one that streams more is cut at the
cap, and the tool says so by URL. A prebuilt download cut before its file
was open no longer raises an uncaught ENOENT from the cleanup.
-
Dependencies: @modelcontextprotocol/sdk ^1.32.1 and playwright
^1.64.0. abap2UI5-bench keeps its gate versions (@abap2ui5/linter
0.8.3, @abaplint/cli 2.120.60): they follow the app-template commit
bench.config.json pins, and moving them starts a new series.