You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A deployment could only narrow its tool surface by tier (NPG_TOOL_LEVEL), which knows nothing about the API token it actually holds — a monitoring token scoped to reads still received every write tool it would only ever get 403s from, wasting context and inviting dead-end tool calls. A new npg_mcp/scopefilter.py adds opt-in scope-aware filtering (NPG_TOOL_SCOPE_FILTER, default off): when enabled, the server exposes only the tools the configured NPG_API_TOKEN can actually call, i.e. the tier manifest intersected with the token's grant. It is applied in main() immediately after toolsets.configure_toolset, so the exposed count reported by /health is the narrowed one and a hidden tool is never listed or callable.
The grant is resolved once at boot, from two endpoints in order: GET /api/v1/api-tokens (needs the literal apitoken:read) matching the row whose token_prefix the configured token starts with, with a fallback to GET /api/v1/auth/me, whose effective_permissions is what NPG enforces per request. The fallback is not cosmetic — a UI-created token can only carry * or one of the 15 legacy scopes, and NPG checks token scopes by exact string, so a scoped token always 403s on the token list; without the fallback the filter could only ever fail open.
Scope matching mirrors upstream APIToken.HasPermission exactly: a tool is exposed only when every permission its routes need is covered by *, the identical string, or an area:* family wildcard. Legacy scopes are not widened into other areas and write does not imply read — that expansion applies to roles, not tokens (verified live: a read_only token reaches proxy/certificate/waf/logs/settings/backup reads and 403s on redirect/access/authprovider/dnsprovider/ddns/audit/dashboard/user/role).
It fails open on every uncertainty, because hiding a tool that would have worked is worse than showing one that returns 403 — a hidden npg_create_* removes the only path that makes a feature exist. Both introspection endpoints failing (403/timeout/transport error), no row matching the token prefix, more than one matching row (ambiguity), an unset NPG_API_TOKEN, or an empty/unparseable permissions array all leave the full tier manifest intact, log at WARNING, and record the reason in hidden_by_scope.reason. Tools carrying no permission annotation are never hidden, so a newly bridged upstream endpoint cannot disappear silently.
Because the snapshot is taken at boot, changing the token's scopes in the NPG UI requires an MCP restart to change the surface — documented rather than papered over with a per-request re-check, which would have put an API round-trip in front of every tools/list.
The tool → permission map is generated, not hand-maintained: npg_mcp/permission_map.py (309 tools) is derived by scripts/derive_tool_permissions.py from NPG's own route-authorisation registry (api/internal/middleware/permissions.go), chosen because only 26 of the 318 swagger operations carry permission annotations. The five runtime-composed tools whose routes the registry cannot see are reviewed in MANUAL_OVERRIDES with a reason each, and the generator reports stale override entries.
npg_get_stack_features now reports what the filter did: hidden_by_scope carries enabled/applied/reason/snapshot/source/token_prefix/stored_permissions/granted_scope_count/hidden_count/hidden_tools/exposed_count/manifest_before/unmapped_tool_count, and is null when the filter is off. The tool is mapped to zero permissions so the filter can never hide the one tool that reports on the filter, and its description= was updated in the same commit to name the new field.
tests/test_scopefilter.py (29 tests) pins the behaviour: scope-matching semantics including the exact-string and area:* cases, every fail-open path, the tier ∩ grant intersection, the hidden_by_scope payload, the zero-permission exemption for the reporting tool, and two invariants — every registered tool must have a permission-map entry (so the map cannot fall behind the tool surface) and every mapped permission must be one NPG actually defines.
Documented in the same commit: NPG_TOOL_SCOPE_FILTER added to the env tables in README.md/README.ko.md and to .env.example (with the boot-snapshot and fail-open rules), a new "Scope Filtering" README section explaining the two introspection paths, the exact-match semantics and the boot-restart requirement, plus the two new modules in the project-structure listing.
Verified on the isolated test stack against a HEAD-pinned local image (local/npg-mcp:307e8a7@sha256:cd895e0d…, image and checkout file hashes proven identical; the published npg-test-mcp image was explicitly not used as evidence), 37/37 checks: filter off = 309 tools with all annotations, filter on with a * token = 309 identical, read_only token = 114, proxy:read token = 28, read tier ∩ grant = 106, unset and invalid token fail open to 309, a scope-hidden tool answering Unknown tool with zero side effects, hidden_by_scope listing exactly the 195 hidden tools, plus a full create→get→update→delete round-trip and 24 regression reads. Test stack left pristine; prod never touched.
Release gates on this commit: 309 tools with 0 duplicates and 0 tool-name changes, tier counts unchanged (read=137 / standard=262 / full=309), ruff check . clean, mypy npg_mcp/ clean, pytest 620 passed, docs --check exit 0 (460-char description cap), and the permission-map drift check in sync at 309 tools.
What's new
NPG_TOOL_SCOPE_FILTER — new env var (default 0, off; truthy 1/true/yes/on) that narrows the exposed tool surface to what the configured API token can actually call, on top of NPG_TOOL_LEVEL. Off by default, in which case the manifest, tool count and tiers are exactly as before.
npg_mcp/permission_map.py — generated tool → NPG permission map covering all 309 tools, and scripts/derive_tool_permissions.py — its generator, which reads NPG's route registry, applies the reviewed MANUAL_OVERRIDES, and fails on stale overrides or unmapped tools.
hidden_by_scope on npg_get_stack_features — the boot snapshot as data (token prefix, granted scopes, the exact hidden tool names, and why the filter failed open if it did), so a restricted server can always report what it hid.
No new tools: the surface stays at 309 tools with the same names, and the tiers are unchanged at read=137 / standard=262 / full=309.
Breaking changes
(none) — the filter is off by default and changes nothing when off: no tool or parameter was renamed, retyped or removed, no default or auth/config requirement changed, and the tier counts are unchanged. npg_get_stack_features gains an additive hidden_by_scope key (always null while the filter is off). Deployments that enable NPG_TOOL_SCOPE_FILTER do accept a narrower surface by design, and must restart the MCP container after changing the token's scopes in the NPG UI.