Repository navigation
Releases: four2mis/npg-mcp
Releases · four2mis/npg-mcp
Release list
v1.7.1
[1.7.1] - 2026-11-04
What changed
- The committed
swagger.yamlwas still the NPG 2.65.4 spec while the code and tool descriptions already targeted 2.66.0, so any swagger-driven gap analysis against this repo under-reported the release's upstream changes (the four/traffic/*endpoints and the new settings/credential fields were invisible in the committed spec). It is now synced from upstream tagv2.66.0: +232/-5 lines covering theGET /traffic/{summary,timeseries,realtime,export}paths with theirTraffic*schemas,GlobalSettings.worker_shutdown_timeout,Route53Credentials.use_default_credentialsplus the masked response field, the SSOgroup_claimmaxLength: 64, and the challenge/dashboard documentation clarifications. Documentation artefact only — no runtime code path reads it. - Three DNS-provider tool descriptions now document the route53
use_default_credentialscredential shape ({use_default_credentials: true}= AWS SDK default credential chain, no static keys; sending keys alongside it is a 400), which upstream added in v2.66.0 and which the tools accepted but never advertised:npg_create_dns_provider,npg_update_dns_provider,npg_test_dns_provider.npg_create_dns_provider's description was additionally rewritten in the standard imperative /REQUIRED:style used by the rest of the surface, with the per-provider credential shapes (cloudflare{api_token}, ionos{api_key_prefix, api_key}, route53 key pair or default chain) spelled out. Descriptions stay under the 460-charDESC_LENcap (438 / 288 / 447 served chars). - Tool signatures are untouched by the description work, so no parameter, type, default or tier moved: the surface remains 313 tools (read 141 / standard 266 / full 313, destructive 47) and the CI description-sync gate sees no signature change.
- Verified at this commit against the disposable test stack with a HEAD-pinned local image (
local/npg-mcp:8437722, containermain.py/toolsets.pysha256 identical to the checkout — the stale publishednpg-test-mcpimage was deliberately not used as evidence): 42/42 live MCP checks pass, including route53 create withuse_default_credentials→ 201 + persistedtrue, the contradictory-keys 400 (route53 takes no access key with use_default_credentials), update/get/delete round-trip, credential masking, and 10 unrelated tools showing no regressions. - Release gates on this commit:
ruff check .clean,mypy npg_mcp/clean,pytest621 passed,regenerate_all_docs.py --checkexit 0 (tool-schemas/README/README.ko in sync at 313 tools, all prose rules andDESC_LENpass), and.env.exampletier counts matchtier_allowed().
What's new
- (none) — no new tools, endpoints or parameters; the exposed surface is unchanged at 313 tools. The
swagger.yamlbundled in the repo now describes the same upstream NPG v2.66.0 API the tools are written against.
Breaking changes
- (none) — documentation- and description-only release: no tool renamed, added or removed, no parameter retyped, no default changed, no tier-membership change.
v1.7.0
[1.7.0] - 2026-11-03
What changed
npg_update_settingscould not setworker_shutdown_timeout, the nginx setting NPG v2.66.0 adds (seconds an old worker waits for its in-flight connections before exiting;0= wait for every connection, max86400, omitted =240). The field is part of upstreamUpdateGlobalSettingsRequest, but it was missing fromTOOL_KWARGS_WHITELISTinnpg_mcp/upstream_models.py, so the client-side strict check rejected the call withValueError: npg_update_settings: unknown field(s) ['worker_shutdown_timeout']before any HTTP request — the only workaround wasstrict=false, i.e. exactly the silent-noop risk the whitelist exists to prevent. The key is now whitelisted (with a# v2.66.0note) andnpg_update_settings'sdescription=names the field, its range and the0semantics in the same commit.- A regression test (
tests/test_kwargs_whitelist.py::test_global_settings_v2_66_worker_shutdown_timeout_accepted) mirrors the existing v2.51 whitelist case and pins both240and the meaningful0value, including a mixed dict withkeepalive_timeout. - Four new upstream
GET /api/v1/traffic/*endpoints (NPG v2.66.0's per-host traffic analytics) had zero MCP coverage. Bridged as read-only tools in a new# ── Traffic ──section right after# ── Dashboard ──, all requiringdashboard:read:npg_get_traffic_summary(response bytes + requests per proxy host with each host's percentage,start/end, optionalproxy_host_idvia the timeseries/export siblings),npg_get_traffic_timeseries(start/end/proxy_host_id— host UUID, the literalunclassified, or omit — bucketed at 5min/hour/day),npg_get_traffic_realtime(fixed last 3 minutes in 10s buckets plus 30s average rates; deliberately nostart/end), andnpg_get_traffic_export(CSV). npg_get_traffic_exportcallsc.get_text(...), notc.get(...): the route servestext/csvandclient.get()unconditionally callsresp.json(), which would raise on a non-JSON body. It returns{"success": True, "data": "<csv text>"}following thenpg_download_log_file/npg_get_metricsprecedent, and its description says the payload is CSV, not JSON.- All four tools declare
expand: bool = False(so the shared result cap applies andexpand=truelifts it — enforced bytests/test_result_cap.py's read-only drift guard) and end their descriptions with the standard cap hint; every description stays under the 460-charDESC_LENcap. - Description drifts left by v2.66.0 behaviour were corrected:
npg_verify_challengenow documents 404 = proxy_host_id not found (the endpoint used to answer 500 for an unknown host),npg_create_sso_provider/npg_update_sso_providerdocumentgroup_claimas max 64 chars (longer is now a 400 instead of a 500), andnpg_get_dashboard_statsnow states that withoutproxy_host_idonly global rollup rows come back while an ID returns only that host's rows. - Generated artefacts were refreshed in the same commit:
npg_mcp/permission_map.pyregenerated from NPG's route registry (the four/traffic/*routes map todashboard:read; 313 tools),tool-schemas.yaml+README.md+README.ko.mdregenerated byregenerate_all_docs.py, and the newTrafficsection registered with that generator's category map so the READMEs gain aTraffic | 4 toolsrow (29 categories). - Tier counts moved with the four read-only tools: read 137 → 141, standard 262 → 266, full 309 → 313 (destructive unchanged at 47), updated in
.env.example, both READMEs and thetests/test_toolsets.py/tests/test_result_cap.py/tests/test_health.pypins. - Verified end to end against the local test stack (NPG v2.66.0 API) with a HEAD-pinned local image —
local/npg-mcp:69933f957b9fd09379c42ab8b87214c30f8355be, not the stale published test image: 313 tools served, all four traffic tools returning live data (summary 288 bytes / 8 requests, timeseries hour point, realtime 10s buckets, CSV export containing the probe host) after driving requests throughnpg-test-proxy,worker_shutdown_timeout=120accepted and reflected bynpg_get_settingsthen restored to0(out-of-range99999rejected upstream with the documented 400),npg_verify_challengesurfacing the new 404 for an unknown host, and an SSO create/delete round-trip with a 64-chargroup_claimplus the 65-char 400 path. 23/23 live checks passed. - Release gates on this commit: 313 tools with no name changes, tier counts match
tier_allowed()(read=141 / standard=266 / full=313, destructive 47),ruff check .clean,mypy npg_mcp/clean,pytest621 passed, docs--checkexit 0 (460-char cap, longest 460), permission map in sync at 313 tools.
What's new
npg_get_traffic_summary— per-proxy-host traffic totals (response bytes, requests, percentage of the window total) for the last 24 h by default or an explicit RFC3339 window.npg_get_traffic_timeseries— the bucketed traffic series (bytes + requests per point,resolution5min/hour/day) for all hosts, one host UUID, or the literalunclassifiedbucket.npg_get_traffic_realtime— live last-3-minutes traffic in 10s buckets with 30s average rates.npg_get_traffic_export— the same per-host figures as CSV text for spreadsheets.worker_shutdown_timeoutis now settable throughnpg_update_settings(0 = wait for every connection, max 86400).
Breaking changes
- (none) — all four tools are additive read-only GETs; no tool was renamed, retyped or removed, no parameter changed type, and no default changed.
npg_update_settingsaccepts one previously-rejected kwargs key, so calls that used to fail client-side now reach the API. Deployments that pinNPG_TOOL_LEVELsee a larger surface (read 141, standard 266, full 313) because the new tools are read-tier.
v1.6.0
[1.6.0] - 2026-11-02
What changed
- 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 get403s from, wasting context and inviting dead-end tool calls. A newnpg_mcp/scopefilter.pyadds opt-in scope-aware filtering (NPG_TOOL_SCOPE_FILTER, default off): when enabled, the server exposes only the tools the configuredNPG_API_TOKENcan actually call, i.e. the tier manifest intersected with the token's grant. It is applied inmain()immediately aftertoolsets.configure_toolset, so the exposed count reported by/healthis 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 literalapitoken:read) matching the row whosetoken_prefixthe configured token starts with, with a fallback toGET /api/v1/auth/me, whoseeffective_permissionsis 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 always403s on the token list; without the fallback the filter could only ever fail open. - Scope matching mirrors upstream
APIToken.HasPermissionexactly: a tool is exposed only when every permission its routes need is covered by*, the identical string, or anarea:*family wildcard. Legacy scopes are not widened into other areas andwritedoes not implyread— that expansion applies to roles, not tokens (verified live: aread_onlytoken reaches proxy/certificate/waf/logs/settings/backup reads and403s 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 hiddennpg_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 unsetNPG_API_TOKEN, or an empty/unparseable permissions array all leave the full tier manifest intact, log atWARNING, and record the reason inhidden_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 byscripts/derive_tool_permissions.pyfrom 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 inMANUAL_OVERRIDESwith a reason each, and the generator reports stale override entries. npg_get_stack_featuresnow reports what the filter did:hidden_by_scopecarriesenabled/applied/reason/snapshot/source/token_prefix/stored_permissions/granted_scope_count/hidden_count/hidden_tools/exposed_count/manifest_before/unmapped_tool_count, and isnullwhen 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 itsdescription=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 andarea:*cases, every fail-open path, the tier ∩ grant intersection, thehidden_by_scopepayload, 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_FILTERadded to the env tables inREADME.md/README.ko.mdand 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 publishednpg-test-mcpimage was explicitly not used as evidence), 37/37 checks: filter off = 309 tools with all annotations, filter on with a*token = 309 identical,read_onlytoken = 114,proxy:readtoken = 28,readtier ∩ grant = 106, unset and invalid token fail open to 309, a scope-hidden tool answeringUnknown toolwith zero side effects,hidden_by_scopelisting 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,pytest620 passed, docs--checkexit 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 (default0, off; truthy1/true/yes/on) that narrows the exposed tool surface to what the configured API token can actually call, on top ofNPG_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, andscripts/derive_tool_permissions.py— its generator, which reads NPG's route registry, applies the reviewedMANUAL_OVERRIDES, and fails on stale overrides or unmapped tools.hidden_by_scopeonnpg_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_featuresgains an additivehidden_by_scopekey (alwaysnullwhile the filter is off). Deployments that enableNPG_TOOL_SCOPE_FILTERdo accept a narrower surface by design, and must restart the MCP container after changing the token's scopes in the NPG UI.
v1.5.0
[1.5.0] - 2026-11-02
What changed
- Read-only tool results were unbounded. A single call could return hundreds of kilobytes of compact JSON — the bundled cloud-provider catalogue alone ships ~700 KB of IP ranges — which blows through a client's context window and, worse, is what an agent actually ends up reasoning over. A new
npg_mcp/resultcap.pycaps every read-only result atNPG_MAX_RESULT_CHARS(default65536compact-JSON characters), applied post-registration inmain()right aftertoolsets.annotate_toolsso validation still runs against each tool's real signature. - The reduction is boundary-only: trailing array items are dropped (binary-searched against the array's own serialized size, so one measure per round), oversized string values are clamped with an
... [clamped N chars]marker across a falling budget ladder, and JSON text is truncated only as a last resort. Nothing is ever cut mid-structure or mid-string, so what a client receives is always complete, parseable JSON. Under-cap results are returned byte-for-byte identical with notruncatedkey. - Over-cap results gain a sibling
truncatedobject —total_chars,returned_chars,omitted_items,clamped_strings, and ahintderived from the tool's own signature (it names the exact narrower parameters that tool really has:limit/offset/sections/lines, or says it has no paging parameters, and always offersexpand=true). The two base64 payload tools (npg_download_backup,npg_get_certificate_download) additionally warn that a shortened payload cannot be decoded. npg_list_cloud_providersdefaulted to the full upstream payload with every CIDR inline. It now projects each provider toid/slug/name/region/enabledplus a derivedip_range_count, and clipsdescriptionto 160 chars — a 2000-CIDR provider drops 38 KB → 338 B.compact=falsereturns the untouched upstream objects byte-for-byte (verified by live equality against a rawGETof the same endpoint), so nothing is renamed, re-ordered or lost on that path. Compaction runs before slicing, sopagingmetadata still reports the true total.- Warning/validation blocks were repeated verbatim across sibling tools, so the same 300–400 character paragraph existed in four to eight descriptions at once — inflating every
tools/listresponse and creating drift risk when one copy was edited. Seven shared blocks (confirm-DNS re-lookup and 400/403/409 handling, paginated-list paging metadata/zero-arg, MCP-side limit/offset slicing, log-autocomplete q/limit filtering, bulk per-item failure isolation, strictkwargsvalidation, global partial-update omitted-field semantics) now live only in a canonical tool, with the 29 registered siblings carrying a short pointer ("... same asnpg_update_settings"). - A new
tests/test_description_cross_refs.pyguard pins the dedup: for eachSHARED_BLOCKSentry the canonical tool must still carry the block, every listed sibling must cross-reference instead of repeating, no unlisted tool may start repeating the block again, and no description may reference a tool name that does not exist (0 danglingnpg_*refs). tests/test_result_cap.py(400 lines) pins the cap end-to-end: env parsing and clamping, every reduction path, the derived hint,expandlifting the cap, the exempt-tool path, idempotent double instrumentation, and two coverage gates — everyreadOnlyHinttool must declareexpand: bool = False, and no mutating tool may grow one.- Verified on the isolated test stack against a HEAD-pinned local image (
local/npg-mcp:a42d22c@sha256:07b633e7…, image and checkout file hashes identical), not the stale published image: 309 tools served with all 309 descriptions byte-identical to the checkout, all 7 shared blocks present only in their canonical tools with all 29 siblings pointing at them, exactly 5 short repeated sentences (the intended pointers), 0 dangling refs, everyREQUIRED:param still named, 39 live read calls across families green, and the three documented validation paths behaving as described. - Release gates on the release commit: 309 tools with 0 duplicates and 0 tool-name changes,
ruff check .clean,mypy npg_mcp/clean,pytest591 passed, docs--checkexit 0 (460-char description cap, longest 460), tier counts matchtier_allowed()(read=137 / standard=262 / full=309).
What's new
npg_get_stack_features— a new zero-argument, report-only, read-tier tool that tells an agent what the deployed NPG release actually serves before it plans a workflow. Ten optional feature families are probed with one GET each: Cloudflare-tunnel state/connections, global fail2ban, global WAF, challenge, GeoIP (flags), plus counts of SSO providers, DNS providers, cloud providers, DDNS records and notification channels.- Per-family isolation is the tool's core contract: each probe runs in its own
try/except, so a permission-scoped API token (403), a family absent from the bridged release (404) or a transport failure comes back asreadable: false+http_statusas report data, never as a failed tool call. It returnsfamilies_probed/families_readable/unreadable_familiesandreport_only: true, and it deliberately does not hide, filter or gate any other tool — the surface is identical whether or not the probed families are configured. NPG_MAX_RESULT_CHARS(new env var, default65536, clamped to[1024, 8000000], invalid values fall back with a warning) with a per-callexpand: bool = Falseescape hatch on all 141 capped read-only tools — one call returns the complete payload.npg_download_log_filekeeps its own 256 KB cap instead of the generic one.compact: bool = Trueonnpg_list_cloud_providers— the built-in catalogue is now cheap by default (derivedip_range_countinstead of the CIDR sets), withcompact=falsefor full fidelity. Same route, same tool, same tier: one switch rather than a second tool.- Tool surface: 309 tools (was 308) with the new stack-features probe; tiers move to read=137 / standard=262 / full=309 (each +1).
Breaking changes
- Read-only tool results larger than
NPG_MAX_RESULT_CHARS(default 65536 compact-JSON characters) are now truncated by default. Callers that relied on receiving a complete payload in one call must passexpand=true(or page with the parameters named in thetruncated.hint). Under-cap results are unchanged. - The default response of
npg_list_cloud_providersno longer contains the fullip_rangesCIDR sets,created_at/updated_atand the untruncateddescription— each row now carriesid/slug/name/region/enabled+ip_range_countand a 160-chardescription. Passcompact=falsefor the previous byte-for-byte payload, ornpg_get_cloud_providerfor one provider in full. - No tool was removed or renamed and no parameter was renamed or retyped; the only signature change is the additive
expandparameter on read-only tools and the additivecompactparameter onnpg_list_cloud_providers. No auth or config requirement changed —NPG_MAX_RESULT_CHARSis optional and defaults preserve prior behaviour for small payloads.
v1.4.5
[1.4.5] - 2026-11-02
What changed
NPG_TOOL_LEVEL=readselected tools by name prefix alone (^npg_(get|list|view|download|check|detect)), and that prefix is a naming convention rather than proof of behaviour. Three probing tools matched it while issuing a POST or writing to the instance, so an agent deployed with the "must not mutate NPG state" tier could still mutate state (or ship a credential to a third party).npg_mcp/toolsets.pynow carries a documented_READ_TIER_DENYLISTthattier_allowed()consults, so the read tier exposes 136 tools instead of 139.npg_check_log_archiveis now excluded from the read tier: its body isPOST /system-settings/log-files/archive/check, which runs a write test (mount type, size, marker, probe) against the archive directory.npg_detect_telegram_chatsis now excluded from the read tier: its body isPOST /notification-channels/detect-telegram-chats, an outbound call to the Telegram Bot API carrying the caller's bot token.npg_check_backup_restoreis now excluded from the read tier: it is GET-routed but creates a throwaway backup row, lists it, then deletes it — a transient DB write the verb check cannot see. This closes the gap where_READONLY_DENYLISTcovered the annotation buttier_allowed()never consulted it.- Excluding was chosen over renaming: a rename is a breaking change for every MCP client and for the hundreds of tool descriptions that cross-reference tool names by exact string.
- The two denylists are now explicitly distinct and documented as such —
_READ_TIER_DENYLISTgoverns tier exposure,_READONLY_DENYLISTgoverns thereadOnlyHintannotation. - Tier counts propagated in the same commit:
.env.example'sNPG_TOOL_LEVELcomment block and the env-var row plus Toolset Levels table in bothREADME.mdandREADME.ko.mdnow read 136 (was 139);standard(261) andfull(308) are unchanged. - 4 new regression tests in
tests/test_toolsets.py: every denylist entry must match the read-name regex and exist inmain.py, each entry must be absent atreadbut present atstandard/full, andtest_read_tier_has_no_mutating_verbsnow fails any futurecheck_*/detect_*tool whose body issues a mutating verb — the class of bug this release fixes cannot silently return. - Verified on the isolated test stack against a HEAD-pinned local image (
local/npg-mcp:e2f7ff8e@sha256:96d5f316…, containermain.py/toolsets.pysha256 identical to the checkout), not the stale published image, using freshverify-*fixtures:read= 136 with all three tools absent fromtools/listand answeringUnknown toolon call,standard= 261 andfull= 308 with all three present and callable (archive checkstatus=ready, backup-restorestatus=passed, telegram detect → structured upstream 400),readOnlyHint=falsefor all three, and 5 unrelated read tools unaffected. Edge cases covered: upper-cased and invalidNPG_TOOL_LEVELfall back to 136/308 as documented. - Release gates on the release commit: 308 tools with 0 duplicates,
ruff check .clean,mypy npg_mcp/clean,pytest508 passed, docs--checkexit 0 (460-char description cap, longestnpg_bulk_import_proxy_hostsat 460), tier counts matchtier_allowed()(read=136 / standard=261 / full=308).
What's new
- The
readtoolset tier now actually honours its documented contract: it contains no tool that writes to NPG or performs an outbound credentialed call, and a regression test enforces that for anycheck_*/detect_*tool added in future. _READ_TIER_DENYLISTis a new, self-documenting extension point innpg_mcp/toolsets.py— each entry carries the reason it is excluded, so the nextcheck_*/detect_*probe with a mutating body only needs one documented line.- No new tools: the surface stays at 308 tools with the same names.
Breaking changes
- At
NPG_TOOL_LEVEL=read, three tools are no longer exposed or callable:npg_check_log_archive,npg_detect_telegram_chats,npg_check_backup_restore. Read-only deployments that relied on them must raiseNPG_TOOL_LEVELtostandardorfull. Nothing else changes — no tool or parameter was renamed or removed, no default, auth or config requirement changed, andstandard(261) andfull(308) are unchanged.
v1.4.4
[1.4.4] - 2026-11-01
What changed
swagger.yaml(the bundled upstream API reference tracked in this repo) was synced from NPG 2.65.3 to 2.65.4. The file is now byte-identical to the upstream v2.65.4 tag (sha256 f2c72ff6…), so the spec shipped with the server no longer lags the API the tools are written against.- The
POST /challenge/verifyoperation — the endpoint behindnpg_verify_challenge— was corrected in the spec to the contract the tool has implemented since v1.4.3: request bodyrequired: [token](the phantomsolutionproperty is gone), withproxy_host_id(uuid, "selects the config") andchallenge_reasondocumented. The 200 response now documents that a rejected CAPTCHA comes back assuccess: falsewitherror/score, and that a success carries the bypasstokenplusexpires_at/expires_in;400(malformed body / badproxy_host_id) and503(challenge not enabled or no CAPTCHA configured) are now described. Previously the spec advertised the wrong request shape, so anyone reading it instead oftool-schemas.yamlwas steered into the exact bug fixed in v1.4.3. - Exploit-rule exclusion operations had their error contracts tightened in the spec:
400now reads "Host or rule ID missing or not a valid ID" (matching the upstream UUID validation), and the per-host exclusion paths gained the404 Proxy host not foundresponse they already returned. The rule-not-found404wording was simplified to "Proxy host or rule not found". - File hygiene: the spec now ends with a trailing newline instead of
\ No newline at end of file. - No server code changed in this release. Re-confirmed on the release commit: 308 tools with 0 duplicates,
ruff check .clean, docs--checkexit 0 (including the 460-char description cap, longestnpg_bulk_import_proxy_hostsat 460), and tier counts read=139 / standard=261 / full=308 matchingtier_allowed().
What's new
- No new tools, parameters or capabilities — this is a reference-spec sync only. The tool surface stays at 308 tools, same names, same tiers (read=139 / standard=261 / full=308), and every tool behaves exactly as in 1.4.3.
- The bundled
swagger.yamlnow documents the per-host CAPTCHA verification contract (proxy_host_id,challenge_reason, rejected-tokensuccess: false) thatnpg_verify_challengeactually implements, so spec readers and tool callers agree.
Breaking changes
- (none) No tool, parameter, default, auth or config requirement changed.
v1.4.3
[1.4.3] - 2026-11-01
What changed
npg_verify_challengesent a phantomsolutionfield that does not exist in upstreammodel.VerifyCaptchaRequest({token, proxy_host_id, challenge_reason}), and never sentproxy_host_id. The request therefore always resolved the global CAPTCHA config: every host that had its own CAPTCHA keys got HTTP 503Challenge is not enabledeven though its per-host challenge config was live. The body now mirrors the upstream model exactly —token(required),proxy_host_id,challenge_reason— andproxy_host_idis sent as a raw string (no percent-encoding) so the service'sif req.ProxyHostID != ""branch selects the per-host config.- The same tool reported a rejected CAPTCHA as a successful call. Upstream answers a failed verification with HTTP 200
{"success": false, "error": "CAPTCHA verification failed", ...}, and the tool used to wrap that in{"success": true, "data": {...}}. It now returns an envelopesuccess: falsewith the upstream message, keeping the full upstream payload underdata. npg_update_proxy_host_challengewas missing three fields of upstreamChallengeConfigRequest:secret_key,page_messageandtheme. Withoutsecret_keyno CAPTCHA could be configured through MCP at all (has_secret_keystayed false, so every verification returned 503Challenge is not configured). All three are optional andNone-defaulted, so the tool remains a partial update — omitted fields are never written.- Both
description=strings were rewritten in the same commit as the signature changes, as the publish workflow's description-sync gate requires (npg_verify_challenge407 chars,npg_update_proxy_host_challenge431 chars, under the 460-char cap). - 12 new regression tests in
tests/test_challenge_verify_config.pypin the verify request body (including "solutionis never sent"), the client-side empty-token rejection, the upstream-success:falseenvelope mapping, the three new challenge-config fields, partial-update behaviour and the route. Full suite: 503 passed. tool-schemas.yamlregenerated for the new parameters. This was needed because the docs drift gate compares tool names only, so a parameter-only change is invisible to--checkand would otherwise have shipped stale parameter documentation.- Verified on the isolated test stack (NPG 2.65.4) against a HEAD-pinned local image
local/npg-mcp:452f340@sha256:7a2a26fb…— not the stale published image. 21/21 checks passed on a throwaway host with its own Turnstile challenge config: schema checks (3 verify params, nosolution),secret_keypersisted (has_secret_key: true),page_message/themeround-tripped, atheme-only call leftchallenge_type/has_secret_key/page_messageuntouched, verify withoutproxy_host_id→ 503Challenge is not enabled(global config, as expected), verify withproxy_host_id→ HTTP 200 plus a 64-char bypass token (expires_in: 86400), a rejected CAPTCHA → envelopesuccess: falsewithdata.error, and an empty token → client-side rejection with no API call. The throwaway host was deleted and confirmed gone afterwards.
What's new
npg_verify_challengecan now verify a CAPTCHA against a per-host challenge config viaproxy_host_id(previously impossible from MCP), and can tag the verification withchallenge_reason(bot_filter/geo_restriction), which upstream records on the issued bypass token and in the challenge log.npg_update_proxy_host_challengecan now fully configure a CAPTCHA:secret_key(the missing piece that made verification impossible), plus the challenge-pagepage_messageandthemeoptions.- No new tools: the surface stays at 308 tools, same names, same tiers (read=139 / standard=261 / full=308).
Breaking changes
npg_verify_challengeno longer acceptssolution— drop it from any call and pass the CAPTCHA provider response astoken. The parameter never reached the API (upstream has no such field), so removals only fix validation errors; verification behaviour improves. Callers that relied on{"success": true}for a rejected CAPTCHA must now checksuccess: false(and may readdata.error).
v1.4.2
[1.4.2] - 2026-10-31
What changed
npg_update_proxy_host'sdescription=advertised SSL toggling and stream configuration, but its signature could not accept them:ssl_enabled,proxy_typeand the eightstream_*params (stream_listen_host,stream_listen_port,stream_protocol,stream_ssl_preread,stream_accept_proxy_protocol,stream_send_proxy_protocol,stream_proxy_connect_timeout,stream_proxy_timeout) were unreachable. Any agent following the published description hit a Pydantic validation error (npg_update_proxy_host() got an unexpected keyword argument), and there was no other way to switch an existing host to stream proxy mode or toggle its SSL through the MCP surface. All 10 are now real parameters wired into the existing param→API field map (50 params served, up from 40).- The same
ssl_enabledgap was closed onnpg_update_proxy_host_simple(9 params served), so the simple/advanced pair stays consistent. - The
description=of all three changed tools was rewritten in the same commit as the signatures, as the publish workflow's description-sync gate requires:npg_update_proxy_hostnow names the stream surface explicitly,npg_update_proxy_host_simplelistsssl_enabled, andnpg_update_cloud_providerstates the upstream update contract (regionandslugare create-only;enabledis a real update field) instead of implying every creation field is updatable. npg_mcp/upstream_models.pynow records why thenpg_update_cloud_providerkwargs whitelist is correct:PUT /cloud-providers/{slug}binds upstreammodel.UpdateCloudProviderRequest, notCreateCloudProvider— the swagger$refis stale. The static preflight scan had twice reportedenabledthere as a phantom field; the comment stops that false positive from recurring.- 11 new regression tests in
tests/test_proxy_host_update_ssl_stream.pypin the reachable parameter sets, the param→body mapping (includingssl_forced→ssl_force_https), the partial-update no-op behaviour, and description↔signature agreement. Full suite: 491 passed. tool-schemas.yamlregenerated. It was stale for all three tools — the docs drift gate compares tool names only, so a parameter-only change is invisible to--checkand would otherwise have shipped parameter documentation missing 10 params.- Verified on the isolated test stack (NPG on 192.168.0.140) against a HEAD-pinned local image
local/npg-mcp:a7b0fd7@sha256:7c5266ef…, not the stale published image — target identity proven by comparing the container's installedmain.pysha256 with the workspace file at HEAD. 21/21 fix-claim checks passed on freshverify-fixtures:ssl_enabledtoggled false→true→false with each step confirmed by an independent GET, all nineproxy_type/stream_*values round-tripped exactly (mismatch={}), ahost_id-only body remained a strict no-op (unrelated fields survived), wrong-typed arguments were rejected at the schema layer, andnpg_update_cloud_providerflippedenabledfalse→true whileregionstayed unchanged after client-side rejection ofregion/slug. Gates: 308 tools, 0 duplicates, ruff + mypy clean, pytest 491 passed, docs--checkexit 0 including the 460-char description cap, tier counts read=139 / standard=261 / full=308, 5 unrelated read-only tools called without regression. All fixtures and the throwaway container were deleted afterwards. - Verification also surfaced three upstream-semantics observations that are not MCP-layer bugs and are not fixed here: switching
proxy_typeback fromstreamtohttpadditionally requiresforward_scheme="http"in the same call (upstream rewrites the stored scheme totcpfor stream hosts),stream_listen_portrange validation is lazy (anhttphost stores an out-of-range port silently; only a stream host rejects it), andnpg_list_cloud_providersreportspaging: "unexpected upstream shape"when upstream returns JSONnullfor an empty collection (cosmetic, pre-existing).
What's new
npg_update_proxy_hostcan now switch an existing host between HTTP and stream proxy modes (proxy_type) and configure stream listeners (stream_listen_host,stream_listen_port,stream_protocol,stream_ssl_preread,stream_accept_proxy_protocol,stream_send_proxy_protocol,stream_proxy_connect_timeout,stream_proxy_timeout) — configuration that previously required hand-crafted API requests.npg_update_proxy_hostandnpg_update_proxy_host_simplecan now togglessl_enabledon an existing host, matching what both descriptions had already promised.- No new tools: the surface stays at 308 tools, same names, same tiers (read=139 / standard=261 / full=308).
Breaking changes
- (none) No tool, parameter, or default was renamed or removed. Every added parameter is optional and
None-defaulted, so omitted fields are still left unchanged — existing calls behave identically.
v1.4.1
[1.4.1] - 2026-10-31
What changed
npg_create_sso_provider's signature now accepts the five parameters its owndescription=had advertised:allowed_email_domains,allowed_emails,group_claim,required_group,default_role_id. All five are optional and are written to thePOST /api/v1/sso-providersbody only when provided (conditional body building, so omitted fields stay omitted). Any agent following the published description previously hit a Pydantic validation error —npg_create_sso_provider() got an unexpected keyword argument.- Measured impact of the pre-fix behaviour while verifying: the previously published image served a 7-property schema (0 of 5 advertised params) and, on a call passing all five, returned
success: truewhile silently dropping 4 of 4 security fields (allowed_email_domains,allowed_emails,required_group,default_role_idcame back as[]/""/null). This was silent data loss on an access-control surface, not just a cosmetically stale description. The fixed build serves all 5 (12 properties) and a live create persists every value exactly, confirmed by an independentnpg_list_sso_providersread-back. - The tool's
description=was rewritten in the same commit as the signature change (431 chars) and now states the auth gate explicitly:(email OR domain) AND required_group; no allowlist means nobody can sign in. It also notesclient_secretis required for most IdPs and thatdefault_role_idmust be an existing role UUID. - 8 new regression tests in
tests/test_sso_provider_create.pypin the parameter set, the conditional body mapping, and description↔signature agreement. Full suite: 480 passed. tool-schemas.yamlregenerated in a follow-up commit (f84c2c0) for the new parameters. This was needed because the docs drift gate compares tool-schemas.yaml by tool name set only, so a parameter-only change is invisible to--checkand would otherwise ship stale parameter documentation silently.- Committed
swagger.yamlsynced to upstream NPG v2.65.3 (3-line diff; no route or request-struct change, so no MCP surface change beyond the fix above). - Verified on the isolated test stack against a HEAD-pinned local image (
local/npg-mcp:f84c2c0), not the stale published image: target identity proven by comparing the container's installedmain.pysha256 with the workspace file at HEAD; 12/12 checks pass, edge cases clean (empty arguments, empty lists, 64-charnamecap rejection, unicode / special characters / IDN values, wrong-type arguments, missing optional params). Gates: 308 tools, 0 duplicate names, docs--checkexit 0 including the 460-char description cap, tier counts read=139 / standard=261 / full=308, 5 unrelated tools called without regression. - Verification also surfaced an upstream constraint that is not an MCP-layer bug and is not fixed here:
group_claimlonger than 64 characters makes bothPOSTandPUT /api/v1/sso-providersreturn HTTP 500{"error":"Operation failed"}although swagger declares nomaxLength(64 chars works,required_groupaccepts 250). Reproduced with raw HTTP and the MCP layer bypassed, so the tools pass the value through faithfully; failure is atomic (no partial row). Filed as an upstream suspicion for the pipeline to verify and report.
What's new
npg_create_sso_providercan now create SSO providers that actually restrict access by email domain (allowed_email_domains), by explicit email allowlist (allowed_emails), by a named OIDC group claim (group_claim, defaultgroups), by a required IdP group (required_group), and can grant a specific existing role to auto-provisioned users (default_role_id) — all previously only reachable by hand-crafting the API request.- No new tools: the surface stays at 308 tools, same names, same tiers.
Breaking changes
- (none) No tool, parameter, or default was renamed or removed; the five added parameters are optional, so existing calls behave identically.
v1.4.0
[1.4.0] - 2026-10-28
What changed
npg_mcp/toolsets.pygained a derived MCPToolAnnotationslayer:annotate_tools(mcp), called once frommain()before the tier filter, attachesreadOnlyHint/destructiveHint/idempotentHint/openWorldHintplus a derivedtitleto every registered tool. Classification is AST-derived from the HTTP verbs each tool body calls inmain.py(get/get_text/get_bytes= read;post/post_file/put/delete= mutating) combined with the existingDESTRUCTIVE_TOOLSnaming registry — there is no hand-maintained 308-name table. Two documented exception sets:_READONLY_DENYLIST(GET routes with side effects —npg_check_backup_restorewrites and deletes a throwaway backup row) and_CLOSED_WORLD_TOOLS(11 closed-catalog reads).- Annotations are attached by mutating the FastMCP
Toolobjects instead of editing decorator lines, so all 308@mcp.tool(name=..., description=...)decorators stay byte-identical and the AST-driven doc tooling (regenerate_all_docs.py,check_tool_descriptions.py, the CI description-sync and DESC_LEN gates) keeps parsing them unchanged. Zero signature changes, zero description changes, zero tools added/removed/renamed — tier counts stay read=139 / standard=261 / full=308. ToolAnnotations.model_validate({...camelCase...})is used rather than kwargs construction: mcp 1.x declares the fields camelCase while mcp 2.x renames them snake_case with camelCase aliases, so alias validation is the only construction valid across the supportedmcp>=1.29,<2range.tests/test_toolsets.pygained 15 tests pinning the derivation itself: every tool annotated, every tool body yields route data, destructive ⇒destructiveHint, GET-only ⇒readOnlyHint, mutating routes never read-only, idempotent rules, denylist/closed-world liveness, title derivation, hints reaching the servedtools/list, plus idempotent and defensiveannotate_toolsbehavior.- README.md / README.ko.md gained a "Tool Annotations" subsection; the
toolsets.pyProject Structure line now mentions annotations. - Verified on the isolated test stack against a HEAD-pinned local image (
local/npg-mcp:576211b, label revision =576211b): 21/21 live checks (308 tools served, all annotated, hint sets 141/47/185/11, spot semantics, derived titles) plus real tool calls —npg_get_settings, fixturenpg_create_proxy_host→npg_delete_proxy_host→ 404 confirm — and 7/7 read-tier checks on a second container atNPG_TOOL_LEVEL=read(139 tools, all annotated, destructive tools still unlisted and uncallable). Gates: pytest 472 passed, ruff + mypy clean, docs drift--checkexit 0 (incl. DESC_LEN),--check-envtier counts match.
What's new
- Every tool now carries standard MCP
ToolAnnotationsintools/list: 141 read-only (readOnlyHint=true), 47 destructive (destructiveHint=true), 185 idempotent reads/PUTpartial updates, andopenWorldHint=falsefor the 11 closed-catalog reads. MCP clients (Hermes, Claude, Cursor) can now filter read-only tools, warn before destructive calls, and batch idempotent ones — with no change to tool execution.
Breaking changes
- (none) Tool names, descriptions, parameter signatures, and toolset tiers are unchanged; annotations are additive client-facing metadata.