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:
co-authored by
Claude Opus 5.5
parent
d86610da8e
commit
611a4571f3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user