cascade-cms-rest-mcp 0.2.2__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 (25) hide show
  1. cascade_cms_rest_mcp-0.2.2/.gitignore +34 -0
  2. cascade_cms_rest_mcp-0.2.2/PKG-INFO +108 -0
  3. cascade_cms_rest_mcp-0.2.2/README.md +93 -0
  4. cascade_cms_rest_mcp-0.2.2/pyproject.toml +25 -0
  5. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/__init__.py +8 -0
  6. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/config.py +56 -0
  7. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/data_structure.py +116 -0
  8. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/errors.py +223 -0
  9. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/formatting.py +150 -0
  10. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/references.py +70 -0
  11. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/resolution.py +90 -0
  12. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/security.py +108 -0
  13. cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/server.py +405 -0
  14. cascade_cms_rest_mcp-0.2.2/tests/conftest.py +42 -0
  15. cascade_cms_rest_mcp-0.2.2/tests/fixtures/contentType_resp.json +33 -0
  16. cascade_cms_rest_mcp-0.2.2/tests/fixtures/raw_data_def.json +15 -0
  17. cascade_cms_rest_mcp-0.2.2/tests/smoke_test.py +154 -0
  18. cascade_cms_rest_mcp-0.2.2/tests/test_config.py +67 -0
  19. cascade_cms_rest_mcp-0.2.2/tests/test_data_structure.py +116 -0
  20. cascade_cms_rest_mcp-0.2.2/tests/test_errors.py +89 -0
  21. cascade_cms_rest_mcp-0.2.2/tests/test_formatting.py +230 -0
  22. cascade_cms_rest_mcp-0.2.2/tests/test_references.py +101 -0
  23. cascade_cms_rest_mcp-0.2.2/tests/test_resolution.py +97 -0
  24. cascade_cms_rest_mcp-0.2.2/tests/test_security.py +28 -0
  25. cascade_cms_rest_mcp-0.2.2/tests/test_server.py +478 -0
@@ -0,0 +1,34 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+
12
+ # Agents
13
+ .claude/
14
+ CLAUDE.md
15
+
16
+ # intermediate skill files (regenerated from the tracked .skill zip)
17
+ skill/cascade-script-writer/
18
+
19
+ # Cache
20
+ cache/
21
+ .pytest_cache/
22
+ .mypy_cache/
23
+ .ruff_cache/
24
+
25
+ # Environment / secrets
26
+ .env
27
+ mcp/tests/output.txt
28
+
29
+ # Editors
30
+ .vscode/
31
+ .idea/
32
+
33
+ # OS
34
+ .DS_Store
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: cascade-cms-rest-mcp
3
+ Version: 0.2.2
4
+ Summary: Read-only MCP server exposing a Hannon Hill Cascade CMS server to MCP clients
5
+ Author: Keith Shark
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: cascade-cms-rest>=3.1.3
9
+ Requires-Dist: mcp<3,>=2.1.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy; extra == 'dev'
12
+ Requires-Dist: pytest; extra == 'dev'
13
+ Requires-Dist: ruff; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # cascade-cms-rest-mcp
17
+
18
+ A local, read-only [MCP](https://modelcontextprotocol.io) server exposing a
19
+ Hannon Hill Cascade CMS server to MCP clients (Claude Desktop, Claude Code,
20
+ etc.), built on top of [`cascade-cms-rest`](https://pypi.org/project/cascade-cms-rest/).
21
+
22
+ Six tools, all read-only — no tool in this server can perform a write
23
+ operation, even indirectly:
24
+
25
+ | Tool | Purpose |
26
+ |---|---|
27
+ | `cascade_search` | Search a site for assets by text |
28
+ | `cascade_read_asset` | Read a single asset by id or by site+path |
29
+ | `cascade_get_data_structure` | A data-bound asset's field schema, resolved from its bound content type/data definition |
30
+ | `cascade_get_page_config` | A data-bound asset's page configuration names/regions |
31
+ | `cascade_root_container_id` | The root container id (e.g. Data Definitions folder) for an asset type on a site |
32
+ | `cascade_list_sites` | List every site on the server |
33
+
34
+ `cascade_get_data_structure` and `cascade_get_page_config` are
35
+ **schema-authoritative**: they resolve field/group/config names from the
36
+ asset's *bound content type or data definition*, not by sampling the one
37
+ asset instance you point them at — so the result is the full schema-valid
38
+ set, not just whatever happens to be populated on that instance.
39
+
40
+ This is a different, easily-confused thing from the *library's*
41
+ `Asset.get_data_structure()` method (used inside generated scripts, not by
42
+ this server) — that one is instance/leaf-only. See the skill's
43
+ `references/asset_api.md` for that distinction, and note that this server is
44
+ read-only by design: *writing* structured data still goes through the
45
+ skill's script-writing path (see its `callback-structured-data-edit.py`
46
+ template).
47
+
48
+ ## Known limitation
49
+
50
+ `resolve_data_definition()` (`src/cascade_cms_rest_mcp/resolution.py`)
51
+ resolves a data-bound asset's data definition two ways: via
52
+ `contentTypeId → contentType.dataDefinitionId` (confirmed against a real
53
+ payload), and via a direct `dataDefinitionId` field on the asset itself
54
+ (present in the code as a fallback, but not yet confirmed against any real
55
+ fixture — harmless no-op if the field is absent). If you hit a data-bound
56
+ asset where resolution fails unexpectedly, this direct-field path is the
57
+ first thing to check.
58
+
59
+ ## Configuration
60
+
61
+ Required environment variables (same names `CascadeWrapperBase` already
62
+ expects — no new credential-naming surface):
63
+
64
+ | Variable | Required | Purpose |
65
+ |---|---|---|
66
+ | `CASCADE_API_KEY` | Yes | Cascade API key |
67
+ | `CASCADE_URL` | Yes | e.g. `https://your-cascade-host:8443` |
68
+ | `SERVER` | No | Cosmetic — log-file naming, defaults to `default` |
69
+ | `CASCADE_MCP_CACHE_DIR` | No | Overrides the default `~/.cache/cascade-cms-mcp` response-cache location |
70
+
71
+ The server fails fast at startup (not on first tool call) if `CASCADE_API_KEY`
72
+ or `CASCADE_URL` is missing.
73
+
74
+ ## Client configuration
75
+
76
+ Install from PyPI (`pip install cascade-cms-rest-mcp`) or run it directly
77
+ with `uvx` — no local checkout needed — then point your MCP client at it:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "cascade-cms": {
83
+ "command": "uvx",
84
+ "args": ["cascade-cms-rest-mcp"],
85
+ "env": {
86
+ "CASCADE_API_KEY": "...",
87
+ "CASCADE_URL": "https://your-cascade-host:8443"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ To run against a local checkout of this repo instead of the published
95
+ package (e.g. testing an unreleased change), use `uvx --from ./mcp
96
+ cascade-cms-rest-mcp` or point `args` at `["--from", "/path/to/cascade-cms-tools/mcp", "cascade-cms-rest-mcp"]`.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pip install -e "./mcp[dev]" # from the repo root
102
+ pytest
103
+ ruff check .
104
+ mypy mcp/src/
105
+ ```
106
+
107
+ `tests/smoke_test.py` is a manual, human-run script against a real dev
108
+ Cascade site (not collected by pytest) — see its own docstring.
@@ -0,0 +1,93 @@
1
+ # cascade-cms-rest-mcp
2
+
3
+ A local, read-only [MCP](https://modelcontextprotocol.io) server exposing a
4
+ Hannon Hill Cascade CMS server to MCP clients (Claude Desktop, Claude Code,
5
+ etc.), built on top of [`cascade-cms-rest`](https://pypi.org/project/cascade-cms-rest/).
6
+
7
+ Six tools, all read-only — no tool in this server can perform a write
8
+ operation, even indirectly:
9
+
10
+ | Tool | Purpose |
11
+ |---|---|
12
+ | `cascade_search` | Search a site for assets by text |
13
+ | `cascade_read_asset` | Read a single asset by id or by site+path |
14
+ | `cascade_get_data_structure` | A data-bound asset's field schema, resolved from its bound content type/data definition |
15
+ | `cascade_get_page_config` | A data-bound asset's page configuration names/regions |
16
+ | `cascade_root_container_id` | The root container id (e.g. Data Definitions folder) for an asset type on a site |
17
+ | `cascade_list_sites` | List every site on the server |
18
+
19
+ `cascade_get_data_structure` and `cascade_get_page_config` are
20
+ **schema-authoritative**: they resolve field/group/config names from the
21
+ asset's *bound content type or data definition*, not by sampling the one
22
+ asset instance you point them at — so the result is the full schema-valid
23
+ set, not just whatever happens to be populated on that instance.
24
+
25
+ This is a different, easily-confused thing from the *library's*
26
+ `Asset.get_data_structure()` method (used inside generated scripts, not by
27
+ this server) — that one is instance/leaf-only. See the skill's
28
+ `references/asset_api.md` for that distinction, and note that this server is
29
+ read-only by design: *writing* structured data still goes through the
30
+ skill's script-writing path (see its `callback-structured-data-edit.py`
31
+ template).
32
+
33
+ ## Known limitation
34
+
35
+ `resolve_data_definition()` (`src/cascade_cms_rest_mcp/resolution.py`)
36
+ resolves a data-bound asset's data definition two ways: via
37
+ `contentTypeId → contentType.dataDefinitionId` (confirmed against a real
38
+ payload), and via a direct `dataDefinitionId` field on the asset itself
39
+ (present in the code as a fallback, but not yet confirmed against any real
40
+ fixture — harmless no-op if the field is absent). If you hit a data-bound
41
+ asset where resolution fails unexpectedly, this direct-field path is the
42
+ first thing to check.
43
+
44
+ ## Configuration
45
+
46
+ Required environment variables (same names `CascadeWrapperBase` already
47
+ expects — no new credential-naming surface):
48
+
49
+ | Variable | Required | Purpose |
50
+ |---|---|---|
51
+ | `CASCADE_API_KEY` | Yes | Cascade API key |
52
+ | `CASCADE_URL` | Yes | e.g. `https://your-cascade-host:8443` |
53
+ | `SERVER` | No | Cosmetic — log-file naming, defaults to `default` |
54
+ | `CASCADE_MCP_CACHE_DIR` | No | Overrides the default `~/.cache/cascade-cms-mcp` response-cache location |
55
+
56
+ The server fails fast at startup (not on first tool call) if `CASCADE_API_KEY`
57
+ or `CASCADE_URL` is missing.
58
+
59
+ ## Client configuration
60
+
61
+ Install from PyPI (`pip install cascade-cms-rest-mcp`) or run it directly
62
+ with `uvx` — no local checkout needed — then point your MCP client at it:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "cascade-cms": {
68
+ "command": "uvx",
69
+ "args": ["cascade-cms-rest-mcp"],
70
+ "env": {
71
+ "CASCADE_API_KEY": "...",
72
+ "CASCADE_URL": "https://your-cascade-host:8443"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ To run against a local checkout of this repo instead of the published
80
+ package (e.g. testing an unreleased change), use `uvx --from ./mcp
81
+ cascade-cms-rest-mcp` or point `args` at `["--from", "/path/to/cascade-cms-tools/mcp", "cascade-cms-rest-mcp"]`.
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ pip install -e "./mcp[dev]" # from the repo root
87
+ pytest
88
+ ruff check .
89
+ mypy mcp/src/
90
+ ```
91
+
92
+ `tests/smoke_test.py` is a manual, human-run script against a real dev
93
+ Cascade site (not collected by pytest) — see its own docstring.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cascade-cms-rest-mcp"
7
+ version = "0.2.2"
8
+ description = "Read-only MCP server exposing a Hannon Hill Cascade CMS server to MCP clients"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "Keith Shark" }]
13
+ dependencies = [
14
+ "cascade-cms-rest>=3.1.3",
15
+ "mcp>=2.1.0,<3",
16
+ ]
17
+
18
+ [project.scripts]
19
+ cascade-cms-rest-mcp = "cascade_cms_rest_mcp.server:main"
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["pytest", "mypy", "ruff"]
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/cascade_cms_rest_mcp"]
@@ -0,0 +1,8 @@
1
+ """cascade-cms-rest-mcp - a local, read-only MCP server wrapping cascade_cms.
2
+
3
+ Lives in its own repo/project (cascade-cms-tools/mcp/), not inside
4
+ cascade-cms-rest itself: the `mcp` SDK dependency, and this package's own
5
+ release cadence, are both independent of the library it wraps. A normal
6
+ `pip install cascade-cms-rest` is completely unaffected by this package's
7
+ existence.
8
+ """
@@ -0,0 +1,56 @@
1
+ """Environment/config wiring for the MCP server, matching CascadeWrapperBase's
2
+ existing expectations exactly - no new credential-naming surface.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from cascade_cms.wrapper import EnvironmentVars
11
+
12
+ # The two hard requirements. SERVER is cosmetic (log-file naming) and
13
+ # defaults, matching every existing skill template's convention.
14
+ _REQUIRED_VARS = ("CASCADE_API_KEY", "CASCADE_URL")
15
+
16
+
17
+ def load_environment_variables() -> EnvironmentVars:
18
+ """Read credentials from os.environ, or raise SystemExit naming exactly
19
+ what's missing. Call this at server startup, before the MCP session
20
+ starts - fail fast, not on first tool call.
21
+ """
22
+ missing = [name for name in _REQUIRED_VARS if not os.environ.get(name)]
23
+ if missing:
24
+ raise SystemExit(
25
+ "cascade-cms-rest-mcp: missing required environment variable(s): "
26
+ + ", ".join(missing)
27
+ + '. Set these in your MCP client\'s "env" block, e.g. '
28
+ '{"CASCADE_API_KEY": "...", "CASCADE_URL": "https://your-cascade-host"}.'
29
+ )
30
+ return EnvironmentVars(
31
+ API_KEY=os.environ["CASCADE_API_KEY"],
32
+ CASCADE_URL=os.environ["CASCADE_URL"],
33
+ SERVER=os.environ.get("SERVER", "default"),
34
+ )
35
+
36
+
37
+ def cache_configuration() -> dict[str, object]:
38
+ """`configurationVariables` for CascadeWrapperBase.
39
+
40
+ Uses an explicit, stable cache path rather than the library's
41
+ CWD-relative `./cache/cache.sqlite` default, since an MCP client
42
+ (uvx, Claude Desktop, ...) launches this process from an unpredictable
43
+ working directory - a relative path would scatter cache dirs.
44
+ Override with CASCADE_MCP_CACHE_DIR for advanced use / testing.
45
+ """
46
+ cache_dir = Path(
47
+ os.environ.get(
48
+ "CASCADE_MCP_CACHE_DIR", str(Path.home() / ".cache" / "cascade-cms-mcp")
49
+ )
50
+ )
51
+ cache_dir.mkdir(parents=True, exist_ok=True)
52
+ return {
53
+ "cache_name": str(cache_dir / "cache.sqlite"),
54
+ "allowed_codes": (200,),
55
+ "allowed_methods": ("GET",),
56
+ }
@@ -0,0 +1,116 @@
1
+ """Parse a Cascade data-definition's `xml` field (a `system-data-structure`
2
+ document) into a generic, schema-authoritative tree, and locate groups/fields
3
+ within it.
4
+
5
+ The element vocabulary observed across three real data-definition documents
6
+ (group, text, asset, shared-field, radio-item, dropdown-item - confirmed via
7
+ direct inspection, not assumed) is broad and open-ended: 15+ distinct attribute
8
+ names across those six tags. Rather than hand-model every possible Cascade field
9
+ type/widget (risking a wrong guess for a shape not yet seen), every element is
10
+ represented uniformly as `{"tag": ..., "attributes": {...}, "children": [...]}`,
11
+ losing no information. `group` elements are containers (children are more schema
12
+ nodes); any other tag is a leaf field (children, if present, are `radio-item`/
13
+ `dropdown-item` option entries, not further schema nodes).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import xml.etree.ElementTree as ET
20
+ from typing import Any
21
+
22
+
23
+ def build_tree(xml_text: str) -> dict[str, Any]:
24
+ """Parse a `system-data-structure` XML document into the generic tree shape.
25
+
26
+ Uses `iterparse(events=("start", "end"))` with an explicit stack, clearing
27
+ each element on its "end" event - the standard memory-bounded iterparse
28
+ idiom, avoiding a full DOM build for a large document. Comments (there are
29
+ several in real data definitions, e.g. `<!--<group ...>-->`) are never
30
+ emitted with just ("start", "end") in the events tuple, so no special
31
+ handling is needed to skip them.
32
+ """
33
+ stack: list[tuple[dict[str, Any], list[dict[str, Any]]]] = []
34
+ root: dict[str, Any] | None = None
35
+ for event, elem in ET.iterparse(io.StringIO(xml_text), events=("start", "end")):
36
+ if event == "start":
37
+ node: dict[str, Any] = {"tag": elem.tag}
38
+ stack.append((node, []))
39
+ else:
40
+ node, children = stack.pop()
41
+ if elem.attrib:
42
+ node["attributes"] = dict(elem.attrib)
43
+ if children:
44
+ node["children"] = children
45
+ if stack:
46
+ stack[-1][1].append(node)
47
+ else:
48
+ root = node
49
+ elem.clear()
50
+ assert root is not None
51
+ return root
52
+
53
+
54
+ def _identifier(node: dict[str, Any]) -> str | None:
55
+ return node.get("attributes", {}).get("identifier")
56
+
57
+
58
+ def find_group(tree: dict[str, Any], group_identifier: str) -> dict[str, Any] | None:
59
+ """Recursive descent for the first `group` node whose `identifier` matches."""
60
+ if tree.get("tag") == "group" and _identifier(tree) == group_identifier:
61
+ return tree
62
+ for child in tree.get("children", []):
63
+ found = find_group(child, group_identifier)
64
+ if found is not None:
65
+ return found
66
+ return None
67
+
68
+
69
+ def find_node(
70
+ group_node: dict[str, Any], node_identifier: str
71
+ ) -> dict[str, Any] | None:
72
+ """DFS within a matched group's children for a leaf field (any non-`group`
73
+ tag) whose `identifier` matches, descending into nested `group` children
74
+ (never itself a match - `node_identifier` names a field, not a group)."""
75
+ for child in group_node.get("children", []):
76
+ if child.get("tag") != "group" and _identifier(child) == node_identifier:
77
+ return child
78
+ if child.get("tag") == "group":
79
+ found = find_node(child, node_identifier)
80
+ if found is not None:
81
+ return found
82
+ return None
83
+
84
+
85
+ def list_children(group_node: dict[str, Any]) -> list[dict[str, Any]]:
86
+ """Immediate children of a matched group, as a compact directory listing -
87
+ sorted alphabetically by identifier for a scannable, deterministic order
88
+ (not raw XML tree-walk order)."""
89
+ entries = [
90
+ {
91
+ "tag": child.get("tag"),
92
+ "identifier": _identifier(child),
93
+ "label": child.get("attributes", {}).get("label"),
94
+ "type": child.get("attributes", {}).get("type"),
95
+ }
96
+ for child in group_node.get("children", [])
97
+ ]
98
+ return sorted(entries, key=lambda e: e["identifier"] or "")
99
+
100
+
101
+ def collect_group_identifiers(tree: dict[str, Any]) -> list[str]:
102
+ """Every group identifier anywhere in the tree, sorted alphabetically.
103
+ Uncapped - truncation for display is applied at the presentation layer,
104
+ not baked into this walk, so `total_count` stays truthful."""
105
+ identifiers: list[str] = []
106
+
107
+ def _walk(node: dict[str, Any]) -> None:
108
+ if node.get("tag") == "group":
109
+ identifier = _identifier(node)
110
+ if identifier:
111
+ identifiers.append(identifier)
112
+ for child in node.get("children", []):
113
+ _walk(child)
114
+
115
+ _walk(tree)
116
+ return sorted(identifiers)
@@ -0,0 +1,223 @@
1
+ """Translate Cascade-level failures (CascadeError values, or exceptions that
2
+ leaked out of submit_requests) into self-correcting MCP ToolErrors. Every
3
+ message here names what was tried, what's actually available, and/or which
4
+ tool to call next - never a bare "not found" or raw traceback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from cascade_cms.cmstypes import Asset, CascadeError, IdentifierType, Path
12
+ from mcp.server.mcpserver.exceptions import ToolError
13
+
14
+ from . import security
15
+ from .formatting import format_names_for_message, sort_by_relevance
16
+
17
+
18
+ def describe_identifier(identifier: IdentifierType | Path) -> str:
19
+ if isinstance(identifier, IdentifierType):
20
+ return f"{identifier.get_type} {identifier.get_id}"
21
+ site = identifier.get("siteName")
22
+ return f"{identifier['asset_type']} at {site}:{identifier['path']}"
23
+
24
+
25
+ def single_result(results: list[Any], *, context: str) -> Any:
26
+ """Unwrap a one-chain `submit_requests()` result.
27
+
28
+ A top-level `submit_requests()` failure returns `[]` (see
29
+ `CascadeWrapperBase.submit_requests`), so this raises a ToolError instead
30
+ of letting `results[0]` raise a bare IndexError.
31
+ """
32
+ if not results:
33
+ raise ToolError(
34
+ f"{context}: no response was received from Cascade - this usually means a "
35
+ "connectivity or session-level failure rather than a bad request. Check "
36
+ "CASCADE_URL and CASCADE_API_KEY, confirm the server is reachable, and retry."
37
+ )
38
+ return results[0]
39
+
40
+
41
+ def search_failure_error(result: CascadeError | Exception) -> ToolError:
42
+ if isinstance(result, CascadeError):
43
+ message = (
44
+ result.message
45
+ or "Cascade reported a search failure with no further detail."
46
+ )
47
+ return ToolError(f"cascade_search failed: {message}")
48
+ return ToolError(
49
+ f"cascade_search failed unexpectedly ({result}). This usually indicates a "
50
+ "connectivity/credential problem rather than a bad query - check CASCADE_URL "
51
+ "and CASCADE_API_KEY."
52
+ )
53
+
54
+
55
+ def blocked_asset_type_error(asset_type: str, *, context: str) -> ToolError:
56
+ if security.is_asset_type_blocked(asset_type):
57
+ return ToolError(
58
+ f"{context}: asset type '{asset_type}' is not accessible via this server. "
59
+ "Access to users, groups, roles, and messages is restricted to protect "
60
+ "confidential information (usernames, hashed passwords, permission "
61
+ "matrices). If you need to work with permissions or user management, "
62
+ "describe the requirement to the user and ask them to handle it "
63
+ "manually rather than through this tool."
64
+ )
65
+ return ToolError(
66
+ f"{context}: asset type '{asset_type}' is not currently supported by this "
67
+ "server's allowlist. If this is a real Cascade asset type that should be "
68
+ "accessible, it needs to be added to ALLOWED_ASSET_TYPES in "
69
+ "cascade_cms_rest_mcp/security.py."
70
+ )
71
+
72
+
73
+ def read_asset_error(
74
+ identifier: IdentifierType | Path,
75
+ result: CascadeError | Exception,
76
+ *,
77
+ context: str = "cascade_read_asset",
78
+ purpose: str | None = None,
79
+ ) -> ToolError:
80
+ described = describe_identifier(identifier)
81
+ target = f" ({purpose})" if purpose else ""
82
+ if isinstance(result, CascadeError):
83
+ message = result.message or "asset not found"
84
+ return ToolError(
85
+ f"{context} failed to read {described}{target}: {message}. "
86
+ "Try cascade_search first to confirm the correct id/type/path, then retry "
87
+ "cascade_read_asset with its result."
88
+ )
89
+ return ToolError(
90
+ f"{context} failed unexpectedly reading {described}{target} ({result}). This usually "
91
+ "indicates a connectivity/credential problem - check CASCADE_URL and CASCADE_API_KEY."
92
+ )
93
+
94
+
95
+ def no_resolvable_reference_error(
96
+ asset: Asset, tried_fields: list[str], *, context: str
97
+ ) -> ToolError:
98
+ tried = ", ".join(tried_fields)
99
+ available = format_names_for_message(sorted(asset._data.keys())) or "(none)"
100
+ return ToolError(
101
+ f"{context}: could not resolve a reference from this asset - tried field(s) {tried}, "
102
+ f"none present. Fields actually on this asset: {available}. Try "
103
+ 'cascade_read_asset(format="detailed") to inspect it directly.'
104
+ )
105
+
106
+
107
+ def no_xml_field_error(data_definition: Asset, *, context: str) -> ToolError:
108
+ return ToolError(
109
+ f"{context}: data definition {data_definition.get('id')} has no 'xml' field, so its "
110
+ 'schema can\'t be parsed. Try cascade_read_asset(format="detailed") on it directly to '
111
+ "inspect what it actually contains."
112
+ )
113
+
114
+
115
+ def group_not_found_error(
116
+ data_definition: Asset, available_groups: list[str], group: str, *, context: str
117
+ ) -> ToolError:
118
+ available = (
119
+ format_names_for_message(sort_by_relevance(available_groups, group)) or "(none)"
120
+ )
121
+ return ToolError(
122
+ f"{context}: group '{group}' not found in data definition "
123
+ f"{data_definition.get('id')}. Available groups: {available}."
124
+ )
125
+
126
+
127
+ def node_not_found_error(
128
+ data_definition: Asset,
129
+ group: str,
130
+ available_identifiers: list[str],
131
+ node_identifier: str,
132
+ *,
133
+ context: str,
134
+ ) -> ToolError:
135
+ available = (
136
+ format_names_for_message(
137
+ sort_by_relevance(available_identifiers, node_identifier)
138
+ )
139
+ or "(none)"
140
+ )
141
+ return ToolError(
142
+ f"{context}: field '{node_identifier}' not found in group '{group}' of data definition "
143
+ f"{data_definition.get('id')}. Available fields in this group: {available}."
144
+ )
145
+
146
+
147
+ def config_name_not_found_error(
148
+ content_type: Asset,
149
+ available_names: list[str],
150
+ configuration_name: str,
151
+ *,
152
+ context: str,
153
+ ) -> ToolError:
154
+ available = (
155
+ format_names_for_message(sort_by_relevance(available_names, configuration_name))
156
+ or "(none)"
157
+ )
158
+ return ToolError(
159
+ f"{context}: page configuration '{configuration_name}' not found for content type "
160
+ f"{content_type.get('name')}. Available configurations: {available}."
161
+ )
162
+
163
+
164
+ def page_region_not_found_error(
165
+ asset: Asset,
166
+ configuration_name: str,
167
+ page_region: str,
168
+ available_regions: list[str],
169
+ *,
170
+ context: str,
171
+ ) -> ToolError:
172
+ available = (
173
+ format_names_for_message(sort_by_relevance(available_regions, page_region))
174
+ or "(none)"
175
+ )
176
+ return ToolError(
177
+ f"{context}: region '{page_region}' not found in configuration '{configuration_name}' "
178
+ f"on this asset instance. This can mean the configuration name is valid but this "
179
+ f"particular asset was never authored with content for this region, not that the name "
180
+ f"is wrong. Regions present on this instance: {available}."
181
+ )
182
+
183
+
184
+ def page_region_requires_configuration_name_error(*, context: str) -> ToolError:
185
+ return ToolError(
186
+ f"{context}: page_region was given without configuration_name - a region only makes "
187
+ "sense within a specific configuration. Supply configuration_name too, or omit "
188
+ "page_region to list available configurations first."
189
+ )
190
+
191
+
192
+ def not_a_site_error(asset: Asset, *, context: str) -> ToolError:
193
+ return ToolError(
194
+ f"{context}: expected a site asset, but read a '{asset.internal_type}' asset "
195
+ f"({asset.get('name')!r}) instead. Root container ids only exist on site assets - "
196
+ "pass the identifier/path of the site itself."
197
+ )
198
+
199
+
200
+ def no_root_container_error(site: Asset, asset_type: str, *, context: str) -> ToolError:
201
+ available = format_names_for_message(sorted(Asset._ROOT_CONTAINER_FIELDS.keys()))
202
+ return ToolError(
203
+ f"{context}: '{asset_type}' has no known root container field on site "
204
+ f"{site.get('name')!r}. Supported asset_type values: {available}."
205
+ )
206
+
207
+
208
+ def list_sites_failure_error(result: CascadeError | Exception) -> ToolError:
209
+ if isinstance(result, CascadeError):
210
+ message = (
211
+ result.message
212
+ or "Cascade reported a listSites failure with no further detail."
213
+ )
214
+ return ToolError(f"cascade_list_sites failed: {message}")
215
+ return ToolError(
216
+ f"cascade_list_sites failed unexpectedly ({result}). This usually indicates a "
217
+ "connectivity/credential problem - check CASCADE_URL and CASCADE_API_KEY."
218
+ )
219
+
220
+
221
+ def unexpected_failure_error(tool_name: str, exc: Exception) -> ToolError:
222
+ """Last-resort translation so no tool body can let a raw traceback leak."""
223
+ return ToolError(f"{tool_name} failed unexpectedly: {exc}")