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.
- cascade_cms_rest_mcp-0.2.2/.gitignore +34 -0
- cascade_cms_rest_mcp-0.2.2/PKG-INFO +108 -0
- cascade_cms_rest_mcp-0.2.2/README.md +93 -0
- cascade_cms_rest_mcp-0.2.2/pyproject.toml +25 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/__init__.py +8 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/config.py +56 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/data_structure.py +116 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/errors.py +223 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/formatting.py +150 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/references.py +70 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/resolution.py +90 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/security.py +108 -0
- cascade_cms_rest_mcp-0.2.2/src/cascade_cms_rest_mcp/server.py +405 -0
- cascade_cms_rest_mcp-0.2.2/tests/conftest.py +42 -0
- cascade_cms_rest_mcp-0.2.2/tests/fixtures/contentType_resp.json +33 -0
- cascade_cms_rest_mcp-0.2.2/tests/fixtures/raw_data_def.json +15 -0
- cascade_cms_rest_mcp-0.2.2/tests/smoke_test.py +154 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_config.py +67 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_data_structure.py +116 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_errors.py +89 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_formatting.py +230 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_references.py +101 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_resolution.py +97 -0
- cascade_cms_rest_mcp-0.2.2/tests/test_security.py +28 -0
- 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}")
|