grp-mcp-plugin 0.81.0rc12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. grp_mcp_plugin-0.81.0rc12/.env.example +49 -0
  2. grp_mcp_plugin-0.81.0rc12/.gitattributes +6 -0
  3. grp_mcp_plugin-0.81.0rc12/.githooks/pre-commit +5 -0
  4. grp_mcp_plugin-0.81.0rc12/.github/workflows/ci.yml +90 -0
  5. grp_mcp_plugin-0.81.0rc12/.gitignore +56 -0
  6. grp_mcp_plugin-0.81.0rc12/CLAUDE.md +199 -0
  7. grp_mcp_plugin-0.81.0rc12/Edit-Connections.bat +66 -0
  8. grp_mcp_plugin-0.81.0rc12/Installation-and-Setup-Guide.md +541 -0
  9. grp_mcp_plugin-0.81.0rc12/KNOWLEDGE.md +3981 -0
  10. grp_mcp_plugin-0.81.0rc12/PKG-INFO +1921 -0
  11. grp_mcp_plugin-0.81.0rc12/README.md +1900 -0
  12. grp_mcp_plugin-0.81.0rc12/connections.example.json +34 -0
  13. grp_mcp_plugin-0.81.0rc12/kb_server.example.json +30 -0
  14. grp_mcp_plugin-0.81.0rc12/packaging/BUILD.md +406 -0
  15. grp_mcp_plugin-0.81.0rc12/packaging/INSTALL-grp-mcp.md +431 -0
  16. grp_mcp_plugin-0.81.0rc12/packaging/extension_README.md +78 -0
  17. grp_mcp_plugin-0.81.0rc12/packaging/manifest.json +137 -0
  18. grp_mcp_plugin-0.81.0rc12/packaging/plugin_README.md +92 -0
  19. grp_mcp_plugin-0.81.0rc12/packaging/plugin_mcp.json +13 -0
  20. grp_mcp_plugin-0.81.0rc12/packaging/server_entry.py +31 -0
  21. grp_mcp_plugin-0.81.0rc12/packaging/setup_entry.py +13 -0
  22. grp_mcp_plugin-0.81.0rc12/packaging/test_frozen.py +133 -0
  23. grp_mcp_plugin-0.81.0rc12/playwright/EXTENDING_ENDPOINTS.md +267 -0
  24. grp_mcp_plugin-0.81.0rc12/playwright/access_rights_modern.js +56 -0
  25. grp_mcp_plugin-0.81.0rc12/playwright/add_endpoint_action.js +121 -0
  26. grp_mcp_plugin-0.81.0rc12/playwright/add_endpoint_action_modern.js +124 -0
  27. grp_mcp_plugin-0.81.0rc12/playwright/add_endpoint_entity.js +167 -0
  28. grp_mcp_plugin-0.81.0rc12/playwright/add_endpoint_entity_modern.js +146 -0
  29. grp_mcp_plugin-0.81.0rc12/playwright/add_endpoint_masterdetail_modern.js +119 -0
  30. grp_mcp_plugin-0.81.0rc12/playwright/change_company_type_modern.js +54 -0
  31. grp_mcp_plugin-0.81.0rc12/playwright/delete_endpoint_entity_modern.js +50 -0
  32. grp_mcp_plugin-0.81.0rc12/playwright/delete_finyear.js +39 -0
  33. grp_mcp_plugin-0.81.0rc12/playwright/inspect_company_fields.js +53 -0
  34. grp_mcp_plugin-0.81.0rc12/playwright/inspect_grid_fields.js +50 -0
  35. grp_mcp_plugin-0.81.0rc12/playwright/inspect_screen_fields.js +43 -0
  36. grp_mcp_plugin-0.81.0rc12/playwright/inspect_segkey.js +41 -0
  37. grp_mcp_plugin-0.81.0rc12/playwright/inspect_sm207060.js +36 -0
  38. grp_mcp_plugin-0.81.0rc12/playwright/inspect_tabs_cs101500.js +72 -0
  39. grp_mcp_plugin-0.81.0rc12/playwright/populate_all_views.js +104 -0
  40. grp_mcp_plugin-0.81.0rc12/playwright/set_features_modern.js +77 -0
  41. grp_mcp_plugin-0.81.0rc12/playwright/setup_finyear.js +120 -0
  42. grp_mcp_plugin-0.81.0rc12/pyproject.toml +108 -0
  43. grp_mcp_plugin-0.81.0rc12/scripts/build_plugin_dist.py +208 -0
  44. grp_mcp_plugin-0.81.0rc12/scripts/check_secrets.py +228 -0
  45. grp_mcp_plugin-0.81.0rc12/scripts/e2e_stdio_check.py +64 -0
  46. grp_mcp_plugin-0.81.0rc12/scripts/eval_find_tool.py +346 -0
  47. grp_mcp_plugin-0.81.0rc12/scripts/mutate_download_by_id.py +152 -0
  48. grp_mcp_plugin-0.81.0rc12/scripts/mutate_name_layer.py +128 -0
  49. grp_mcp_plugin-0.81.0rc12/scripts/mutate_sdk_seam.py +116 -0
  50. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/__init__.py +45 -0
  51. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/acumatica.py +790 -0
  52. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/aspx.py +2285 -0
  53. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/cli.py +65 -0
  54. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/config.py +279 -0
  55. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/customization.py +195 -0
  56. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/enforcement.py +669 -0
  57. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/guide_nudge.py +68 -0
  58. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/kb.py +158 -0
  59. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/kb_client.py +480 -0
  60. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/loaders.py +75 -0
  61. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/preflight.py +120 -0
  62. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/report_params.py +135 -0
  63. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/screen.py +5714 -0
  64. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/server.py +12843 -0
  65. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/setup_map.json +860 -0
  66. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/setup_ui.py +100 -0
  67. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/tool_search.py +411 -0
  68. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/ui.py +759 -0
  69. grp_mcp_plugin-0.81.0rc12/src/grp_mcp/writeback.py +470 -0
  70. grp_mcp_plugin-0.81.0rc12/tests/test_aspx.py +1524 -0
  71. grp_mcp_plugin-0.81.0rc12/tests/test_defect_register.py +914 -0
  72. grp_mcp_plugin-0.81.0rc12/tests/test_kb.py +91 -0
  73. grp_mcp_plugin-0.81.0rc12/tests/test_module10_bugfixes.py +343 -0
  74. grp_mcp_plugin-0.81.0rc12/tests/test_smoke.py +10403 -0
  75. grp_mcp_plugin-0.81.0rc12/tests/test_tool_search.py +367 -0
@@ -0,0 +1,49 @@
1
+ # Default Acumatica instance (used when a tool is called without an `instance` arg).
2
+ # For multiple instances, use connections.json instead (see connections.example.json).
3
+
4
+ GRP_MCP_BASE_URL=https://your-instance.acumatica.com
5
+ GRP_MCP_CLIENT_ID=YOUR_CLIENT_ID@CompanyLogin
6
+ GRP_MCP_CLIENT_SECRET=your_client_secret
7
+ GRP_MCP_USERNAME=admin
8
+ GRP_MCP_PASSWORD=your_password
9
+
10
+ # Optional. Defaults shown.
11
+ GRP_MCP_ENDPOINT_NAME=Default
12
+ GRP_MCP_ENDPOINT_VERSION=24.200.001
13
+ # Tenant/company login name, used for OData (Generic Inquiry) calls and
14
+ # Customization API cookie login.
15
+ GRP_MCP_TENANT=
16
+ # Optional login branch.
17
+ GRP_MCP_BRANCH=
18
+ # --- write gates (default read-only; opt in) ---
19
+ # Allow record mutations (create/update, load, actions, import-scenario, note, attach).
20
+ GRP_MCP_ALLOW_WRITE=false
21
+ # Allow record deletes (stricter than write).
22
+ GRP_MCP_ALLOW_DELETE=false
23
+ # Gate Customization API writes (publish/import/unpublish). Default off.
24
+ # WARNING: publishing is website-level and affects ALL tenants on the instance.
25
+ GRP_MCP_ALLOW_PUBLISH=false
26
+ # Filesystem sandbox (read_roots / write_roots / max_file_bytes) is configured via
27
+ # connections.json only — see connections.example.json. Empty roots confine
28
+ # filesystem tools to the process working directory; whole-disk access is the
29
+ # explicit `allow_unrestricted_fs: true` opt-in. (This comment said "empty roots =
30
+ # unrestricted" for several releases. It was wrong — config.py is authoritative.)
31
+
32
+ # --- tool surface (token cost) ---
33
+ # grp-mcp registers 100+ tools. Serving all of them costs roughly 58k tokens of
34
+ # context BEFORE the agent does anything — about 28% of a 200k window
35
+ # (measured 2026-08-17 against the live registry).
36
+ #
37
+ # Set this to 1 and only a small core is LISTED (~10k tokens, an 82% cut). The
38
+ # rest are not removed: they stay classified, gated and preflighted, and the
39
+ # agent reaches them with find_tool("what you want") then call_tool(name, args).
40
+ # Recommended for day-to-day use; leave it off if a client of yours calls a
41
+ # non-core tool by name, since a pruned tool is not directly callable.
42
+ GRP_MCP_CORE_ONLY=0
43
+
44
+ # Persisting connection-profile changes from inside the agent (add_instance /
45
+ # remove_instance / set_active_instance with persist) additionally requires:
46
+ # GRP_MCP_ALLOW_ADMIN=1
47
+
48
+ # Path to a JSON file holding multiple named instances (optional).
49
+ # GRP_MCP_CONNECTIONS=C:\Temp\grp-mcp\connections.json
@@ -0,0 +1,6 @@
1
+ # core.hooksPath points at .githooks, so these run as shell scripts. Git on Windows
2
+ # checks text files out as CRLF by default, and /bin/sh refuses a script whose
3
+ # shebang line ends in \r ("bad interpreter: /bin/sh^M"). Pin them to LF so the
4
+ # hooks stay executable on every clone, including this one after a fresh checkout.
5
+ .githooks/** text eol=lf
6
+ scripts/*.py text eol=lf
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+ # Content-based secret gate. See scripts/check_secrets.py for why this exists
3
+ # rather than relying on .gitignore alone (2026-07-27 leak).
4
+ # Enable once per clone: git config core.hooksPath .githooks
5
+ python "$(git rev-parse --show-toplevel)/scripts/check_secrets.py" --staged || exit 1
@@ -0,0 +1,90 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["**"]
6
+ pull_request:
7
+ schedule:
8
+ # Weekly, for the mcp-latest job below. Nothing else here needs a timer.
9
+ - cron: "17 6 * * 1"
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ test:
17
+ # Windows is where this actually runs (the paths, the .exe launchers, the
18
+ # file-locking failure modes), so it is not an afterthought row here.
19
+ runs-on: ${{ matrix.os }}
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ include:
24
+ - { os: ubuntu-latest, python: "3.10" }
25
+ - { os: ubuntu-latest, python: "3.13" }
26
+ - { os: windows-latest, python: "3.13" }
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python }}
32
+ cache: pip
33
+ # The `search` extra is load-bearing for the COUNT: without fastembed,
34
+ # tests/test_tool_search.py importorskips and the suite silently runs ~21
35
+ # tests short while still reporting all-green. Those tests build the corpus
36
+ # from the registry and never download the ONNX model.
37
+ - run: pip install -e ".[dev,search]"
38
+ - run: python -m pytest tests/ -q
39
+
40
+ lint:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: actions/setup-python@v5
45
+ with: { python-version: "3.13", cache: pip }
46
+ - run: pip install -e ".[dev]"
47
+ # Narrow by design (E9,F,BLE) — see the [tool.ruff] comment in pyproject.
48
+ - run: python -m ruff check src/ tests/ scripts/
49
+
50
+ package:
51
+ # The sdist is PUBLIC and permanent. Twice now something private reached a
52
+ # BUILT artifact while the working tree looked clean: machine-specific
53
+ # absolute paths (2026-08-17) and a live bearer token in a test (same day,
54
+ # caught by scanning the artifact, not the tree). Scanning the tree is not
55
+ # the same check, so this scans what would actually be uploaded.
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - uses: actions/checkout@v4
59
+ - uses: actions/setup-python@v5
60
+ with: { python-version: "3.13", cache: pip }
61
+ - run: pip install build
62
+ - run: python -m build
63
+ - name: Scan the built sdist for credentials
64
+ run: python scripts/check_secrets.py --sdist dist/*.tar.gz
65
+ - name: Scan the built wheel for credentials
66
+ run: python scripts/check_secrets.py --sdist dist/*.whl
67
+ - uses: actions/upload-artifact@v4
68
+ with:
69
+ name: dist
70
+ path: dist/
71
+
72
+ mcp-latest:
73
+ # EARLY WARNING, not a gate. server.py reaches two private MCP-SDK attributes
74
+ # through the seam (_tool_manager._tools, _lowlevel_server.instructions).
75
+ # pyproject pins mcp>=2,<3, so a minor release cannot break an install — but
76
+ # it can break US, silently, at the next unpinned upgrade. This job finds out
77
+ # first. It is allowed to fail.
78
+ runs-on: ubuntu-latest
79
+ continue-on-error: true
80
+ steps:
81
+ - uses: actions/checkout@v4
82
+ - uses: actions/setup-python@v5
83
+ with: { python-version: "3.13", cache: pip }
84
+ - run: pip install -e ".[dev,search]"
85
+ - run: pip install --upgrade mcp
86
+ - run: python -c "import mcp; print('mcp', mcp.__version__ if hasattr(mcp,'__version__') else '?')"
87
+ # The three seam-degradation tests are the ones that matter here: the seam
88
+ # returns {} / "" / False rather than raising, so a moved attribute shows up
89
+ # as a silent no-op unless something asserts otherwise.
90
+ - run: python -m pytest tests/ -q
@@ -0,0 +1,56 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ env/
11
+
12
+ # Secrets / local config
13
+ .env
14
+ connections.json
15
+ kb_server.json
16
+
17
+ # IDE
18
+ .vscode/
19
+ .idea/
20
+ .DS_Store
21
+
22
+ # Snapshot dumps (business data)
23
+ snapshots/
24
+
25
+ # Playwright debug screenshots
26
+ playwright/shots/
27
+
28
+ # Credential files — the bare name was not enough. A connections.json.bak-<date>
29
+ # backup (written by the config editor) slipped past `connections.json` and was
30
+ # committed to a PUBLIC repo on 2026-07-27. Match every variant, not the one name.
31
+ connections.json*
32
+ kb_server.json*
33
+ *.bak
34
+ *.bak-*
35
+ .env
36
+
37
+ # graphify is no longer used in this repo. The rule stays because the tool writes
38
+ # multi-MB generated graphs into these directories the moment anyone runs it, and
39
+ # `git status` collapses an untracked directory to a single line — which is exactly
40
+ # how a 2.8 MB graph.json gets swept into a `git add -A` unnoticed.
41
+ graphify-out/
42
+ graphify-out-code*/
43
+
44
+ # generated repowiki from an external tool — huge, and some paths exceed the Windows limit
45
+ .qoder/
46
+
47
+ # Frozen-build scratch (packaging/BUILD.md). buildenv/ already ignores itself via
48
+ # the .gitignore venv writes, but pybuild/ does not: only pybuild/dist matches the
49
+ # `dist/` rule above, so work/ and the generated .spec were left untracked and a
50
+ # `git add -A` would sweep in ~100MB of PyInstaller intermediates.
51
+ buildenv/
52
+ pybuild/
53
+ extbuild/
54
+ setupbuild/
55
+ ext/
56
+ *.mcpb
@@ -0,0 +1,199 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `grp-mcp` — an MCP server (stdio, `mcp.server.MCPServer`) that exposes an **Acumatica ERP** instance as ~120 agent
8
+ tools. Python 3.10+, `src/` layout, published to PyPI as `grp-mcp`. Entry points: `grp_mcp.server:main`
9
+ (the MCP server) and `grp_mcp.ui:main` (a localhost config page).
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ pip install -e ".[dev]"
15
+ ```
16
+
17
+ ```bash
18
+ ./.venv/Scripts/python.exe -m pytest tests/ -q
19
+ ```
20
+
21
+ 711 tests, all pure logic — **no live Acumatica instance is needed or used**. The `search` extra is
22
+ required for the full count: without `fastembed`, `tests/test_tool_search.py` collects 0 and the suite
23
+ silently runs 21 tests short. Single test / file:
24
+
25
+ ```bash
26
+ ./.venv/Scripts/python.exe -m pytest tests/test_smoke.py::test_delete_gate_is_independent_of_write -q
27
+ ```
28
+
29
+ Retrieval quality of `find_tool` (not part of pytest — these print a score, they don't assert):
30
+
31
+ ```bash
32
+ ./.venv/Scripts/python.exe scripts/eval_find_tool.py
33
+ ```
34
+
35
+ One runner, four datasets (`--dataset core|holdout|holdout2|holdout3`, `--list` to see them; all four
36
+ by default). `core` is the calibration set and the hold-outs are the anti-overfit checks written after
37
+ it was already passing — tuning against `core` alone overfits it. `holdout2` also measures the NOISE
38
+ CEILING: off-domain queries that must reach no tool, which is what the WEAK threshold has to clear.
39
+ `eval_find_tool_mined.py` stays separate — it mines intents from local Claude transcripts rather than
40
+ reading a fixed list, and reads machine-private files. `scripts/mutate_*.py` are mutation checks — they break a module on purpose,
41
+ run the matching test file, and report which tests went red (a test nobody has watched fail is a claim,
42
+ not a check). They restore the file in a `finally`.
43
+
44
+ Lint (narrow by design — `E9,F,BLE`; style rules are deliberately not selected):
45
+
46
+ ```bash
47
+ ./.venv/Scripts/python.exe -m ruff check src/ tests/ scripts/
48
+ ```
49
+
50
+ `BLE` is the point of enabling it at all: the package carries 150+ `# noqa: BLE001` suppressions, and
51
+ until 2026-08-17 they were written for a linter that never ran, so a deliberate blind except was
52
+ indistinguishable from an accidental one. Every suppression now carries a reason.
53
+
54
+ CI (`.github/workflows/ci.yml`) runs the suite on ubuntu 3.10/3.13 and windows 3.13, lints, and builds
55
+ the sdist+wheel and scans **the built artifacts** with `check_secrets.py` — scanning the tree is not the
56
+ same check, and twice something private reached an artifact while the tree looked clean. A
57
+ `continue-on-error` job tracks the latest `mcp` release as early warning for the SDK seam.
58
+
59
+ Enable the secret gate once per clone (content-based, scans staged diffs — see `scripts/check_secrets.py`
60
+ for the 2026-07-27 leak it exists for):
61
+
62
+ ```bash
63
+ git config core.hooksPath .githooks
64
+ ```
65
+
66
+ Release (maintainers): bump `version` in `pyproject.toml`, `python -m build`, **audit the built sdist AND
67
+ wheel** (`python scripts/check_secrets.py --sdist dist/*.tar.gz`, plus grep for tenant/host names — the
68
+ sdist is public and permanent), then `twine upload dist/grp_mcp-<version>*`. Full checklist in
69
+ KNOWLEDGE.md §10.
70
+
71
+ ## Architecture
72
+
73
+ ### Five client planes
74
+
75
+ The whole design follows from one fact: **no single Acumatica API can drive every screen.** Each plane
76
+ has capability holes the others cover, so tools are grouped by plane, not by feature.
77
+
78
+ | Plane | Module | Reaches | Cannot |
79
+ |---|---|---|---|
80
+ | Contract REST (OAuth2) | `acumatica.py` | endpoint entities, bulk CRUD, actions, reports | context/master-detail screens |
81
+ | DAC / GI OData | `acumatica.py` | any table incl. config singletons, mandatory-field CSDL | read-only |
82
+ | Classic screen SOAP | `screen.py` (`ScreenClient`) | context/wizard screens by replaying UI commands | can't address an *existing* grid row by key; some action tags are silent no-ops |
83
+ | Modern UI-JSON | `screen.py` (`ui_*` methods) | grid CRUD, dialogs, enum values, live action state | `/structure` absent on older builds; one container per view name, so classic "ViewName: 2" duplicates are invisible |
84
+ | Classic ASPX callbacks | `aspx.py` | the REAL validation text behind a failed save; actions + confirms on keyed records | not a general write path |
85
+
86
+ Both classic SOAP and modern UI-JSON live in `screen.py` because they **share one login session**
87
+ (`_SESSION_CACHE`) — logins hold scarce "Web Services API Users" seats (trial = 2), so sessions are
88
+ pooled and logged out at shutdown. Never mix planes inside one session: they hold separate dirty-graph
89
+ state and collide.
90
+
91
+ KNOWLEDGE.md §1 is the plane routing table; §3 (classic command mechanics) and §4 (modern JSON protocol)
92
+ are the protocol references. Read them before touching `screen.py`.
93
+
94
+ ### Tool registration and the core-only mode
95
+
96
+ Every tool is a `@mcp.tool()` in `server.py` (12.5k lines — one file by design; `_TOOL_NOTES` holds the
97
+ long prose that would otherwise bloat docstrings/schemas). At import time, after all registrations:
98
+
99
+ 1. `_apply_core_only_gating()` snapshots the full registry into `_ALL_TOOLS`, then — **only if
100
+ `GRP_MCP_CORE_ONLY=1`** — deletes everything outside `CORE_TOOLS` from the SDK's registry.
101
+ 2. `_sync_instruction_tool_count()` rewrites the served MCP `instructions` with the real count and, in
102
+ core-only mode, the sentence telling the client that the unlisted tail exists.
103
+
104
+ Pruning the registry (rather than skipping the decorator) keeps **one definition path**: a hidden tool is
105
+ still classified, still gated, still preflighted, and still reachable via `find_tool` (semantic search,
106
+ `tool_search.py`) → `call_tool(tool, args)`, which dispatches to the same function.
107
+
108
+ Both steps need MCP-SDK internals, so all of it goes through the **SDK seam** at the top of `server.py`
109
+ — `_registry()`, `_instructions()`, `_set_instructions()`. Never reach `mcp._tool_manager` /
110
+ `mcp._lowlevel_server` anywhere else; `test_sdk_private_access_stays_inside_the_seam` fails the build if
111
+ you do. `_registry()` returns the SDK's **live mutable dict, not a copy** — core-only prunes it in place,
112
+ so a copy silently turns pruning into a no-op that still reports a pruned count. The seam degrades (`{}`
113
+ / `""` / `False`) rather than raising, and three tests plus `scripts/mutate_sdk_seam.py` keep that
114
+ degradation loud.
115
+
116
+ The seam paid for itself on the `mcp` 2.0 migration (`FastMCP` → `MCPServer`, `mcp.server.fastmcp`
117
+ deleted, low-level Server rebuilt): the whole cost inside `server.py` was the import, the constructor,
118
+ and one attribute name. Two measured facts worth keeping — `instructions` has **no setter in either
119
+ major** (`fset` is `None`), so the private write path is permanent rather than a v1 wart; and 2.0 still
120
+ exposes `_tool_manager._tools` as a dict of tools carrying `.description` and `.parameters`, which is
121
+ why `_registry()` and the `find_tool` corpus needed no change. Neither is promised by anything.
122
+ `pyproject.toml` now pins `mcp>=2,<3`; the sibling **grp-kb** still pins `<2`, which is fine — it runs
123
+ as a separate subprocess with its own environment, so the two majors never share an interpreter.
124
+
125
+ ### Enforcement layer (`enforcement.py`) — the load-bearing invariant
126
+
127
+ `TOOL_CLASS` maps **every** registered tool to a risk class (`read`/`write`/`delete`/`publish`/`bg_job`/
128
+ `diagnostic_write`/`filesystem`/`admin`). It documents and checks; it does **not** re-route gates — the
129
+ real `_require_write` / `_require_delete` / `_require_publish` / `_require_admin` calls stay inline in
130
+ `server.py` (several are runtime branches — a tool that write-gates normally but delete-gates on a
131
+ destructive action). AST-based tests in `tests/test_smoke.py` cross-check the registry against the source
132
+ so an unguarded mutation cannot ship. Escape hatches are `GATELESS_MUTATION_ALLOW` and `DELEGATED_GATE`
133
+ — keep them tiny and justified.
134
+
135
+ ### Adding or changing a tool — the checklist the tests enforce
136
+
137
+ 1. `@mcp.tool()` in `server.py`.
138
+ 2. Add it to `enforcement.TOOL_CLASS` (`test_every_registered_tool_is_classified` fails otherwise, in
139
+ both directions — a stale entry is also an error).
140
+ 3. If it mutates ERP data, call a real `_require_*` gate **lexically in the tool body**
141
+ (`test_erp_mutation_tools_are_actually_gated` reads the AST, not the runtime).
142
+ 4. Wire the KB write-preflight: `@_preflight_write(...)` below `@mcp.tool()`, or inline for the four
143
+ arteries in `PREFLIGHT_INLINE`. Coverage is tested.
144
+ 5. If it's a read whose result should carry the nudge, `@_hint_only`.
145
+
146
+ ### Write honesty
147
+
148
+ A clean `ok:true` from Acumatica proves nothing — success-shaped no-ops are routine. So:
149
+
150
+ - `writeback.py` (pure, no I/O) compares a read-back against what was sent. Its rule: **never report
151
+ `rejected` for something merely uncomparable** — a false "your write failed" sends the caller off to
152
+ redo work that already succeeded.
153
+ - `enforcement.verify_state()` / `normalize_verification()` collapse the scattered per-plane flags
154
+ (`save_verified`, `delete_verified`, `grid_rows_readable`, `graph_is_dirty`) into one
155
+ `verified`/`rejected`/`unverified` verdict, worst-case wins. There is deliberately **no rollback
156
+ state**: automatic cross-plane undo is unsafe, so a failure is surfaced, never reversed.
157
+ - Any non-verified mutation gets `_DIAGNOSE_ROUTE` stapled on — it names *instruments* (`aspx_screen_action`,
158
+ `diagnose_save_error`, `SMPerformanceInfoTraceMessages`), never a guessed cause. That wording is
159
+ deliberate; see the comment above it.
160
+
161
+ ### KB-first policy
162
+
163
+ Before any write, the server itself consults the KB — it does not trust the agent to have done it.
164
+ `kb_client.py` spawns the sibling **grp-kb** package as a subprocess (launch spec from
165
+ `GRP_MCP_KB_SERVER` / `kb_server.json`) and runs its semantic search; `kb.py` digests the relevant
166
+ section of the **bundled** `KNOWLEDGE.md`. Server-produced evidence is unfakeable; caller-supplied
167
+ references are only an attestation. `preflight.py` applies the level from
168
+ `Instance.effective_enforcement()` — `off` (dev default) / `warn` (production default) / `enforce`
169
+ (blocks the write if grp-kb was unreachable). Preflight never raises: a broken preflight must not break
170
+ a write.
171
+
172
+ ### Safety model (`config.py`)
173
+
174
+ Per-instance gates, **read-only by default**: `allow_write`, `allow_delete` (strictly stronger, enforced
175
+ across *all* planes), `allow_publish`. Persisting connection-profile changes additionally need
176
+ `GRP_MCP_ALLOW_ADMIN=1`. Every authenticated request is pinned to the profile's origin *and* base-URL
177
+ path prefix (SSRF / token-exfiltration guard, incl. sibling apps on the same host). Filesystem tools
178
+ resolve roots through `Instance.effective_roots()`: configured roots win, empty roots confine to the
179
+ process CWD, and whole-disk access is the explicit `allow_unrestricted_fs=true` opt-in. `config.py` is
180
+ authoritative here — the prose in README.md and Installation-and-Setup-Guide.md described the older
181
+ opt-in-sandbox behaviour for several releases before it was caught.
182
+
183
+ ## Repo conventions
184
+
185
+ - **KNOWLEDGE.md is the engineering log, not decoration.** Each numbered section records a measured
186
+ finding (what was tried, what the instrument said, what was wrong). Tools quote it at runtime and the
187
+ preflight digests it. Headings must match `## N. Title` or `## §N Title` — sections 19+ use the `§`
188
+ form, and a heading that matches neither becomes invisible to `knowledge()` *and* to the write
189
+ preflight (this happened for five releases; see §26).
190
+ - Comments here explain **why**, usually with a date and a measurement, and often name the wrong
191
+ hypothesis that cost time. Match that register; don't strip those comments as noise.
192
+ - Commit subjects are imperative and state the *effect*, not the file touched — "delete_entity now
193
+ proves the record is gone", "Close a filesystem-sandbox existence oracle (authorize before stat)". A
194
+ release commit carries a trailing `(vX.Y.Z)` and pairs with a Status entry in README.md plus a
195
+ KNOWLEDGE.md section.
196
+ - `connections.json`, `kb_server.json`, `.env`, `*.bak*` and `snapshots/` are gitignored and must never
197
+ be committed or packaged — `pyproject.toml`'s sdist `exclude` list is the second line of defence.
198
+ - `graphify` was evaluated and removed on purpose (2026-07-29). Don't propose reinstating it; the
199
+ `.gitignore` rules for it stay because the tool writes multi-MB artifacts the moment anyone runs it.
@@ -0,0 +1,66 @@
1
+ @echo off
2
+ setlocal
3
+ title grp-mcp - launching config UI
4
+
5
+ rem Always use the connections.json sitting next to THIS script, no matter
6
+ rem what folder the double-click happened from. This is the fix for the
7
+ rem classic "config UI wrote to the wrong file" problem - it removes the
8
+ rem current-working-directory dependency entirely.
9
+ set "GRP_MCP_CONNECTIONS=%~dp0connections.json"
10
+
11
+ echo ============================================
12
+ echo grp-mcp Configuration UI
13
+ echo ============================================
14
+ echo Connections file: %GRP_MCP_CONNECTIONS%
15
+ echo ============================================
16
+ echo.
17
+
18
+ rem WHICH BUILD IS ABOUT TO RUN? This used to be invisible, and it bit us.
19
+ rem "uvx --from grp-mcp" resolves the PUBLISHED PyPI package, and --refresh
20
+ rem actively re-pulls it - so on a machine that HAS a source clone, this script
21
+ rem happily ignored the local code and served the released UI instead. Symptom:
22
+ rem you edit ui.py, re-run this, and get the old page with an old build number.
23
+ rem So: prefer a local install sitting next to this script, and fall back to
24
+ rem PyPI only when there is not one (the normal end-user case).
25
+ set "LOCAL_UI=%~dp0.venv\Scripts\grp-mcp-ui.exe"
26
+
27
+ if exist "%LOCAL_UI%" (
28
+ echo Build: LOCAL - %LOCAL_UI%
29
+ echo ^(rename .venv to force the published PyPI build instead^)
30
+ echo.
31
+ echo Starting the config server in a new window...
32
+ start "grp-mcp config UI - close this window when you are done editing" cmd /k "%LOCAL_UI%"
33
+ goto :opened
34
+ )
35
+
36
+ where uvx >nul 2>nul
37
+ if errorlevel 1 (
38
+ echo ERROR: "uvx" was not found on your PATH.
39
+ echo.
40
+ echo Install it first, then close ALL terminal windows and re-run this:
41
+ echo powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 ^| iex"
42
+ echo.
43
+ pause
44
+ exit /b 1
45
+ )
46
+
47
+ echo Build: PUBLISHED from PyPI ^(no local .venv beside this script^)
48
+ echo.
49
+ echo Starting the config server in a new window...
50
+ rem --refresh forces uvx to pull the LATEST grp-mcp from PyPI instead of a stale
51
+ rem cached wheel. Without it, uvx keeps serving whatever version it first cached,
52
+ rem so shipped UI fixes never reach you (symptom: the page hangs on "loading...").
53
+ start "grp-mcp config UI - close this window when you are done editing" cmd /k uvx --refresh --from grp-mcp grp-mcp-ui
54
+
55
+ :opened
56
+ echo Waiting for it to start...
57
+ timeout /t 3 /nobreak >nul
58
+
59
+ echo Opening your browser...
60
+ start "" http://127.0.0.1:8765
61
+
62
+ echo.
63
+ echo Done - edit your instance^(s^) in the browser tab that just opened.
64
+ echo The page header shows a build number; if it looks stale, hard-refresh with
65
+ echo Ctrl+Shift+R. When finished, close the "grp-mcp config UI" window.
66
+ timeout /t 4 >nul