Move tool profiles to a root file and version the release as 0.5.0

- Tool profiles come from a committed `.napseer-tools.json` at the
  project root (schema napseer.tools.v1). The unreleased tool_profiles
  field in .napseer/project.json is removed: released clients reject
  unknown locator fields, and 0.4.0 treats any extra file inside .napseer/
  as private state needing migration (a test loads the released 0.4.0
  check from c427e24 to prove the root file is invisible to it).
  Environment still overrides; unusable files warn and fall back to core.
- CLI release 0.5.0 with distribution contract 2026-09-29; the minimum
  supported contract stays 2026-09-10. The newer-contract rejection test
  now derives its date from the installer's own contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
HumbleTurtle
2026-09-28 23:33:22 -03:00
co-authored by Claude Opus 5.5
parent d86610da8e
commit 611a4571f3
5 changed files with 283 additions and 61 deletions
+79 -35
View File
@@ -326,9 +326,12 @@ AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
PROJECT_LOCATOR_FILENAME = "project.json"
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
# Optional, non-identity locator preferences. Values are validated where
# they are consumed so an unknown preference never blocks project identity.
PROJECT_LOCATOR_OPTIONAL_FIELDS = {"tool_profiles"}
# Committed, non-identity tool preferences live at the project root, outside
# .napseer/: the locator schema stays strict and released clients that treat
# other .napseer/ entries as private state awaiting migration never see it.
PROJECT_TOOLS_SCHEMA = "napseer.tools.v1"
PROJECT_TOOLS_FILENAME = ".napseer-tools.json"
PROJECT_TOOLS_MAX_BYTES = 4096
GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json"
VAULT_PATH = AUTH_DIR / "vault.json"
LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key"
@@ -874,7 +877,7 @@ def validate_project_locator(data):
"project_locator_invalid",
".napseer/project.json must contain one JSON object.",
)
if not PROJECT_LOCATOR_FIELDS <= set(data) or set(data) - PROJECT_LOCATOR_FIELDS - PROJECT_LOCATOR_OPTIONAL_FIELDS:
if set(data) != PROJECT_LOCATOR_FIELDS:
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.",
@@ -922,15 +925,12 @@ def validate_project_locator(data):
".napseer/project.json project_slug is invalid.",
)
locator = {
return {
"schema": PROJECT_LOCATOR_SCHEMA,
"base_url": raw_base_url,
"project_id": project_id,
"project_slug": project_slug,
}
if "tool_profiles" in data:
locator["tool_profiles"] = data["tool_profiles"]
return locator
def load_project_locator():
@@ -1048,9 +1048,6 @@ def write_project_locator(project, *, base_url=None):
"project_locator_mismatch",
"Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project or intentionally update the locator before continuing.",
)
if existing and "tool_profiles" in existing:
# Preserve the repository's committed preference on rewrite.
desired["tool_profiles"] = existing["tool_profiles"]
ensure_project_locator_ignore_policy()
path = project_locator_path()
@@ -19723,31 +19720,77 @@ REMOVED_TOOL_REPLACEMENTS = {
CONFIGURED_TOOL_PROFILES = None
TOOL_PROFILE_LOCATOR_MAX_ITEMS = 16
TOOL_PROFILE_FILE_MAX_ITEMS = 16
def locator_tool_profiles():
"""Return (profiles, declared, warnings) from the committed project locator.
def project_tools_path():
"""Committed tool preferences at the project root that owns the locator."""
return PROJECT_DIR.parent / PROJECT_TOOLS_FILENAME
Unknown or malformed entries are ignored with a warning; a locator that
cannot be read yields no profiles here (identity errors surface elsewhere).
def read_project_tools_file():
"""Return the parsed committed tools file, None when absent.
Raises ValueError with a safe message when the file cannot be used. The
file must be a bounded regular JSON file inside the resolved project root.
"""
import stat
path = project_tools_path()
try:
if not path.exists() and not path.is_symlink():
return None
if path.is_symlink():
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file, not a symlink.")
root = path.parent.resolve()
if path.resolve().parent != root:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must stay inside the project root.")
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
with os.fdopen(fd, "rb") as source:
status = os.fstat(source.fileno())
if not stat.S_ISREG(status.st_mode):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file.")
if status.st_size > PROJECT_TOOLS_MAX_BYTES:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.")
body = source.read(PROJECT_TOOLS_MAX_BYTES + 1)
except OSError:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} could not be read.") from None
if len(body) > PROJECT_TOOLS_MAX_BYTES:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.")
try:
data = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, ValueError, RecursionError):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} is not valid UTF-8 JSON.") from None
if not isinstance(data, dict):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must contain one JSON object.")
if data.get("schema") != PROJECT_TOOLS_SCHEMA:
raise ValueError(f"{PROJECT_TOOLS_FILENAME} schema must be {PROJECT_TOOLS_SCHEMA}.")
if not isinstance(data.get("tool_profiles"), list):
raise ValueError(f"{PROJECT_TOOLS_FILENAME} tool_profiles must be an array of profile names.")
return data
def project_file_tool_profiles():
"""Return (profiles, applied, warnings) from the committed tools file.
An unusable file is ignored with a warning (core only); unknown profile
names are dropped with a warning. The strict project locator is never
consulted for tool preferences.
"""
try:
locator = load_project_locator()
except SafeToolError as exc:
return set(), False, [{"code": "project_locator_unreadable", "message": exc.safe_message}]
if not locator or "tool_profiles" not in locator:
return set(), False, []
raw = locator["tool_profiles"]
if not isinstance(raw, list):
return set(), True, [{
"code": "invalid_tool_profiles",
"message": ".napseer/project.json tool_profiles must be an array of profile names; it was ignored.",
data = read_project_tools_file()
except ValueError as exc:
return set(), False, [{
"code": "tools_file_invalid",
"message": f"{exc} It was ignored; only core tools are enabled unless NAPSEER_TOOL_PROFILES is set.",
}]
if data is None:
return set(), False, []
raw = data["tool_profiles"]
known = {"all"} | set(TOOL_PROFILES)
profiles = set()
unknown = []
for value in raw[:TOOL_PROFILE_LOCATOR_MAX_ITEMS]:
for value in raw[:TOOL_PROFILE_FILE_MAX_ITEMS]:
name = value.strip().lower() if isinstance(value, str) else None
if name in known:
profiles.add(name)
@@ -19757,14 +19800,14 @@ def locator_tool_profiles():
if unknown:
warnings.append({
"code": "unknown_tool_profile",
"message": ".napseer/project.json tool_profiles contains unknown profile names that were ignored. Supported profiles: "
"message": f"{PROJECT_TOOLS_FILENAME} tool_profiles contains unknown profile names that were ignored. Supported profiles: "
+ ", ".join(sorted(TOOL_PROFILES)) + ".",
"ignored": unknown,
})
if len(raw) > TOOL_PROFILE_LOCATOR_MAX_ITEMS:
if len(raw) > TOOL_PROFILE_FILE_MAX_ITEMS:
warnings.append({
"code": "tool_profiles_truncated",
"message": f".napseer/project.json tool_profiles was truncated to {TOOL_PROFILE_LOCATOR_MAX_ITEMS} entries.",
"message": f"{PROJECT_TOOLS_FILENAME} tool_profiles was truncated to {TOOL_PROFILE_FILE_MAX_ITEMS} entries.",
})
return profiles, True, warnings
@@ -19773,7 +19816,7 @@ def tool_profile_configuration():
"""Return (profiles, source, warnings).
Precedence: in-process override, then a non-empty NAPSEER_TOOL_PROFILES,
then the committed locator's optional tool_profiles, then core only.
then the committed .napseer-tools.json at the project root, then core only.
"""
if CONFIGURED_TOOL_PROFILES is not None:
configured = set(CONFIGURED_TOOL_PROFILES)
@@ -19786,8 +19829,8 @@ def tool_profile_configuration():
source = "environment"
warnings = []
else:
configured, declared, warnings = locator_tool_profiles()
source = "project_locator" if declared else "default"
configured, applied, warnings = project_file_tool_profiles()
source = "tools_file" if applied else "default"
return configured, source, warnings
unknown = set(configured) - ({"all"} | set(TOOL_PROFILES))
if unknown:
@@ -20050,7 +20093,8 @@ def contract_profile(args=None):
"active": sorted(active_profiles),
"source": profile_source,
"warnings": profile_warnings,
"configuration": "NAPSEER_TOOL_PROFILES, else .napseer/project.json tool_profiles",
"configuration": "NAPSEER_TOOL_PROFILES, else .napseer-tools.json tool_profiles at the project root, else core",
"tools_file": str(project_tools_path()),
"available": {
name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items()
},
@@ -20926,7 +20970,7 @@ def _call_tool_impl_dispatch(name, args):
"tool_profile_required",
f"{canonical_name} requires the '{profile}' tool profile. Set "
f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server, "
f"or add it to tool_profiles in .napseer/project.json.",
f"or add it to tool_profiles in .napseer-tools.json at the project root.",
)
if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS:
require_unlocked(name)