mcp-ivolatility-data 0.1.5__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 (65) hide show
  1. mcp_ivolatility_data-0.1.5/.dockerignore +5 -0
  2. mcp_ivolatility_data-0.1.5/.gitignore +8 -0
  3. mcp_ivolatility_data-0.1.5/.mcpbignore +17 -0
  4. mcp_ivolatility_data-0.1.5/Dockerfile +29 -0
  5. mcp_ivolatility_data-0.1.5/LICENSE +21 -0
  6. mcp_ivolatility_data-0.1.5/PKG-INFO +58 -0
  7. mcp_ivolatility_data-0.1.5/README.md +72 -0
  8. mcp_ivolatility_data-0.1.5/README.pypi.md +38 -0
  9. mcp_ivolatility_data-0.1.5/build_instructions.py +384 -0
  10. mcp_ivolatility_data-0.1.5/docs/ARCHITECTURE.md +135 -0
  11. mcp_ivolatility_data-0.1.5/docs/INSTALL.md +131 -0
  12. mcp_ivolatility_data-0.1.5/docs/USAGE.md +128 -0
  13. mcp_ivolatility_data-0.1.5/instructions_map.yml +55 -0
  14. mcp_ivolatility_data-0.1.5/instructions_seals.json +50 -0
  15. mcp_ivolatility_data-0.1.5/k8s/ivol-mcp.yaml +115 -0
  16. mcp_ivolatility_data-0.1.5/manifest.json +58 -0
  17. mcp_ivolatility_data-0.1.5/pyproject.toml +51 -0
  18. mcp_ivolatility_data-0.1.5/run-data-mcp.sh +13 -0
  19. mcp_ivolatility_data-0.1.5/scripts/build-mcpb.sh +43 -0
  20. mcp_ivolatility_data-0.1.5/scripts/stamp-pypi-version.py +33 -0
  21. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/__init__.py +91 -0
  22. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/__main__.py +4 -0
  23. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/assembly.py +50 -0
  24. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/constants.py +201 -0
  25. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/formatters.py +189 -0
  26. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/functions.py +933 -0
  27. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/index.py +660 -0
  28. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/index_openapi.py +219 -0
  29. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/index_prompts.py +158 -0
  30. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/server.py +887 -0
  31. mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/store.py +1058 -0
  32. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/__init__.py +25 -0
  33. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/__main__.py +4 -0
  34. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/index_supplement.yml +113 -0
  35. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/00-role.md +40 -0
  36. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/10-discovery.md +65 -0
  37. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/10-discovery.stdio.md +5 -0
  38. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/20-access.md +22 -0
  39. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/30-behavior.md +14 -0
  40. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/30-behavior.remote.md +12 -0
  41. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/30-behavior.stdio.md +12 -0
  42. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/40-calling.md +27 -0
  43. mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/openapi_ivolatility.yml +11277 -0
  44. mcp_ivolatility_data-0.1.5/tests/__init__.py +0 -0
  45. mcp_ivolatility_data-0.1.5/tests/fixtures/aggs.md +85 -0
  46. mcp_ivolatility_data-0.1.5/tests/fixtures/llms-full.txt +13337 -0
  47. mcp_ivolatility_data-0.1.5/tests/fixtures/llms-partial.txt +833 -0
  48. mcp_ivolatility_data-0.1.5/tests/fixtures/llms.txt +13 -0
  49. mcp_ivolatility_data-0.1.5/tests/integration/__init__.py +0 -0
  50. mcp_ivolatility_data-0.1.5/tests/integration/conftest.py +169 -0
  51. mcp_ivolatility_data-0.1.5/tests/integration/mock_api_server.py +133 -0
  52. mcp_ivolatility_data-0.1.5/tests/integration/mock_llms_txt.py +29 -0
  53. mcp_ivolatility_data-0.1.5/tests/integration/responses.py +321 -0
  54. mcp_ivolatility_data-0.1.5/tests/integration/test_error_handling.py +176 -0
  55. mcp_ivolatility_data-0.1.5/tests/integration/test_http_transport.py +145 -0
  56. mcp_ivolatility_data-0.1.5/tests/integration/test_inprocess.py +314 -0
  57. mcp_ivolatility_data-0.1.5/tests/integration/test_stdio_transport.py +139 -0
  58. mcp_ivolatility_data-0.1.5/tests/integration/test_workflows.py +382 -0
  59. mcp_ivolatility_data-0.1.5/tests/test_assembly.py +51 -0
  60. mcp_ivolatility_data-0.1.5/tests/test_formatters.py +683 -0
  61. mcp_ivolatility_data-0.1.5/tests/test_functions.py +1356 -0
  62. mcp_ivolatility_data-0.1.5/tests/test_index.py +1229 -0
  63. mcp_ivolatility_data-0.1.5/tests/test_server.py +1158 -0
  64. mcp_ivolatility_data-0.1.5/tests/test_store.py +3118 -0
  65. mcp_ivolatility_data-0.1.5/uv.lock +883 -0
@@ -0,0 +1,5 @@
1
+ core/.venv
2
+ **/__pycache__
3
+ **/*.pyc
4
+ core/tests
5
+ *.mcpb
@@ -0,0 +1,8 @@
1
+ # Build artifacts / local env — not in repo
2
+ *.mcpb
3
+ **/.venv/
4
+ **/__pycache__/
5
+ *.pyc
6
+ ._*
7
+ .DS_Store
8
+ dist/
@@ -0,0 +1,17 @@
1
+ .venv
2
+ **/__pycache__
3
+ **/*.pyc
4
+ tests
5
+ docs
6
+ k8s
7
+ scripts
8
+ dist
9
+ Dockerfile
10
+ .dockerignore
11
+ .gitignore
12
+ build_instructions.py
13
+ instructions_map.yml
14
+ instructions_seals.json
15
+ run-data-mcp.sh
16
+ *.mcpb
17
+ .pytest_cache
@@ -0,0 +1,29 @@
1
+ # Remote iVolatility data MCP connector (streamable-http) for self-hosting (k8s).
2
+ # Build context = mcp/ directory.
3
+ FROM python:3.12-slim
4
+
5
+ WORKDIR /app
6
+
7
+ # Runtime deps (same set as pyproject.toml). Pinned-light; tighten later.
8
+ RUN pip install --no-cache-dir \
9
+ "mcp>=1.27.0" \
10
+ "certifi>=2026.2.25" \
11
+ "httpx>=0.28.1" \
12
+ "numpy>=2.4.4" \
13
+ "python-dotenv>=1.2.2" \
14
+ "pydantic>=2.12.5" \
15
+ "sqlglot>=30.4.0" \
16
+ "pyyaml>=6.0"
17
+
18
+ # Source layout (engine + connector wrapper incl. bundled index/instructions).
19
+ COPY src /app/src
20
+
21
+ ENV PYTHONPATH=/app/src \
22
+ MCP_TRANSPORT=streamable-http \
23
+ MCP_HOST=0.0.0.0 \
24
+ MCP_PORT=8000
25
+ # IVOL_API_KEY is provided at deploy time (k8s secret / env) — NEVER baked in.
26
+
27
+ EXPOSE 8000
28
+
29
+ CMD ["python", "-m", "mcp_ivolatility_data"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Massive.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-ivolatility-data
3
+ Version: 0.1.5
4
+ Summary: iVolatility market data in any MCP client: option chains, IV surface/IVX/skew, greeks, NBBO, earnings
5
+ Project-URL: Homepage, https://www.ivolatility.com
6
+ Project-URL: Documentation, https://www.ivolatility.com/api-features/
7
+ Author-email: iVolatility <support@ivolatility.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: certifi>=2026.2.25
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: mcp>=1.27.0
14
+ Requires-Dist: numpy>=2.4.4
15
+ Requires-Dist: pydantic>=2.12.5
16
+ Requires-Dist: python-dotenv>=1.2.2
17
+ Requires-Dist: pyyaml>=6.0
18
+ Requires-Dist: sqlglot>=30.4.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mcp-ivolatility-data
22
+
23
+ iVolatility market data MCP server for local use by data-oriented clients
24
+ (quants with their own Claude, Cursor, or any MCP client). One package ships
25
+ the engine (`ivol_mcp_core`) and the bundled endpoint index, and runs the tools
26
+ (`search_endpoints` → `call_api` → `query_data`) against the iVolatility REST API.
27
+
28
+ No hosting — the client installs it locally and uses their own `IVOL_API_KEY`.
29
+ Access control stays on the REST API (tariff/limits by key).
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv tool install mcp-ivolatility-data
35
+ claude mcp add ivolatility -e IVOL_API_KEY=<key> -- mcp-ivolatility-data
36
+ ```
37
+
38
+ Or any `mcp.json`-style client (Cursor, …): `command: mcp-ivolatility-data`,
39
+ `env: {IVOL_API_KEY: <key>}`. `uv` brings its own Python — the client does not
40
+ install Python manually. Claude Desktop users: prefer the `.mcpb` bundle
41
+ (double-click install, key form) or the remote connector URL.
42
+
43
+ ## What it covers
44
+
45
+ 65 endpoints: full REST (equities/futures EOD+intraday, RT REST, fixed income,
46
+ curves) from the bundled OpenAPI spec, plus `/account/access` and WebSocket
47
+ discovery from the prompt supplement. (RT market-wide screeners intentionally
48
+ excluded.)
49
+
50
+ ## Env
51
+
52
+ | Var | Required | Purpose |
53
+ |-----|----------|---------|
54
+ | `IVOL_API_KEY` | yes | iVolatility API key (sent as `?apiKey=`) |
55
+ | `IVOL_API_BASE_URL` | no | defaults to `https://restapi.ivolatility.com` |
56
+
57
+ Index sources (`openapi_ivolatility.yml`, `index_supplement.yml`) are bundled
58
+ with the package; `IVOL_OPENAPI_PATH` / `IVOL_SUPPLEMENT_PATH` override them.
@@ -0,0 +1,72 @@
1
+ # iVolatility Market Data — MCP Connector
2
+
3
+ One MCP connector that gives any MCP client (claude.ai, Claude Code, Claude Desktop,
4
+ Cursor…) live iVolatility data: option chains, IV/IVX/skew, greeks, equity & futures
5
+ prices, NBBO, earnings, instrument discovery. Built on the `massive-com/mcp_massive`
6
+ architecture (MIT) — composable tools + an in-memory store.
7
+
8
+ **Docs:**
9
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how it works and why it's built this way
10
+ - [docs/USAGE.md](docs/USAGE.md) — connecting from claude.ai / Claude Code / Desktop, examples, troubleshooting
11
+ - [docs/INSTALL.md](docs/INSTALL.md) — deploying remote, building the local `.mcpb`, env vars, tests
12
+
13
+ ## TL;DR
14
+
15
+ - **Remote**: `https://mcp.ivolatility.com/mcp?apiKey=<user key>` — the path is `/mcp`.
16
+ Per-user auth; no server-side key exists.
17
+ - **Local**: `scripts/build-mcpb.sh` → `dist/ivolatility-data-<ver>.mcpb` for
18
+ Claude Desktop (adds real file export, which is disabled on remote).
19
+ - Five tools: `search_endpoints → call_api → query_data` (+ `export_data`,
20
+ `account_access`). Per-user in-memory tables, 1h TTL, isolated per apiKey.
21
+
22
+ ## Layout
23
+
24
+ ```
25
+ mcp/
26
+ pyproject.toml ONE PyPI distribution (mcp-ivolatility-data) shipping both packages
27
+ src/
28
+ ivol_mcp_core/ engine (mcp_massive fork): tools, store, index, assembly
29
+ mcp_ivolatility_data/ wrapper + CANONICAL index sources + instructions
30
+ openapi_ivolatility.yml ← the only copy of the spec (63 endpoints)
31
+ index_supplement.yml ← FTS enrich keys + account/WS overlay
32
+ instructions/*.md ← prompt chunks (+ .stdio/.remote transport variants)
33
+ tests/ unit suite (integration/ is Massive-era, broken — see TODO)
34
+ k8s/ivol-mcp.yaml remote deployment (ns mcp)
35
+ scripts/build-mcpb.sh + scripts/stamp-pypi-version.py
36
+ manifest.json .mcpb passport (bundle root = mcp/)
37
+ build_instructions.py + instructions_map.yml + instructions_seals.json ← prompt watchdog
38
+ ```
39
+
40
+ Dedup rule: the spec and supplement live **only** inside `src/mcp_ivolatility_data/` —
41
+ no copies at the mcp/ root (two copies silently drifted apart once already, 2026-07-01/02).
42
+
43
+ ## Status / TODO
44
+
45
+ - [x] package the `uv tool install` local path — ONE PyPI project `mcp-ivolatility-data`
46
+ (both import packages in one wheel), CI `publish:pypi-mcp` on `mcp-v*` tags
47
+ (2026-07-09; wheel + entry point smoke-tested from a clean venv locally)
48
+ - [ ] verify the PyPI path end-to-end after the first published `mcp-v*` tag (`uv tool install` from real PyPI; needs `PYPI_TOKEN_MCP` CI var set)
49
+ - [ ] CI job running the unit tests (632 passing locally via `cd mcp && uv run pytest tests --ignore=tests/integration`; CI doesn't run them yet)
50
+ - [ ] tests/integration is Massive-era and broken (`configure_credentials` API drift) — rewrite against the iVol server or drop; unit tests import its `mock_llms_txt`, so don't delete blindly
51
+ - [ ] futures-intraday market label (minor: `Minute Historical Futures Intervals` → Futures)
52
+
53
+ ## History
54
+
55
+ - **2026-06-26** — fork of `mcp_massive`, iVol adaptation (auth, envelope, download
56
+ pattern, index from OpenAPI + supplement).
57
+ - **2026-06-29/30** — remote deploy on kvscode (mcp.ivolatility.com) + vps-s backup;
58
+ prompt watchdog (MR !89).
59
+ - **2026-07-01** — security fix (shared server key removed), guidance moved into
60
+ tool descriptions (claude.ai ignores MCP `instructions`, #43749).
61
+ - **2026-07-02** — per-user store isolation, transport-aware instructions, honest
62
+ remote export, source dedup, test-suite revival, `.mcpb` build script.
63
+ - **Connector #2 (backtesting)** — built 2026-07-01 (`run_backtest_in_pod` via a
64
+ per-user Coder token), **removed 2026-07-02**: the full IVolAI ships as a Claude Code
65
+ Plugin (`ivolai-dist`) — remote MCP can't deliver the role/protocol to the model
66
+ (#43749, #56243), and inside a workspace pod the agent runs backtests natively.
67
+ Code remains in the git history of `feature/mcp-connector`; images
68
+ `ivol-mcp-backtesting:v0.1.0–v0.1.3` remain in Harbor.
69
+
70
+ ## Origin
71
+
72
+ `core/` is a fork of `massive-com/mcp_massive` (MIT, see `core/LICENSE`).
@@ -0,0 +1,38 @@
1
+ # mcp-ivolatility-data
2
+
3
+ iVolatility market data MCP server for local use by data-oriented clients
4
+ (quants with their own Claude, Cursor, or any MCP client). One package ships
5
+ the engine (`ivol_mcp_core`) and the bundled endpoint index, and runs the tools
6
+ (`search_endpoints` → `call_api` → `query_data`) against the iVolatility REST API.
7
+
8
+ No hosting — the client installs it locally and uses their own `IVOL_API_KEY`.
9
+ Access control stays on the REST API (tariff/limits by key).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv tool install mcp-ivolatility-data
15
+ claude mcp add ivolatility -e IVOL_API_KEY=<key> -- mcp-ivolatility-data
16
+ ```
17
+
18
+ Or any `mcp.json`-style client (Cursor, …): `command: mcp-ivolatility-data`,
19
+ `env: {IVOL_API_KEY: <key>}`. `uv` brings its own Python — the client does not
20
+ install Python manually. Claude Desktop users: prefer the `.mcpb` bundle
21
+ (double-click install, key form) or the remote connector URL.
22
+
23
+ ## What it covers
24
+
25
+ 65 endpoints: full REST (equities/futures EOD+intraday, RT REST, fixed income,
26
+ curves) from the bundled OpenAPI spec, plus `/account/access` and WebSocket
27
+ discovery from the prompt supplement. (RT market-wide screeners intentionally
28
+ excluded.)
29
+
30
+ ## Env
31
+
32
+ | Var | Required | Purpose |
33
+ |-----|----------|---------|
34
+ | `IVOL_API_KEY` | yes | iVolatility API key (sent as `?apiKey=`) |
35
+ | `IVOL_API_BASE_URL` | no | defaults to `https://restapi.ivolatility.com` |
36
+
37
+ Index sources (`openapi_ivolatility.yml`, `index_supplement.yml`) are bundled
38
+ with the package; `IVOL_OPENAPI_PATH` / `IVOL_SUPPLEMENT_PATH` override them.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env python3
2
+ """mcp prompt watchdog — резалка-сторож (the inverse-aware companion to claude/split.py).
3
+
4
+ The mcp instruction chunks (``data/.../instructions/*.md``) are a MANUAL distillation
5
+ of the master prompts (``users_data/basic/prompts/*.md``) — short, rewritten
6
+ cheat-sheets, NOT 1:1 slices. A regex cannot reproduce a distillation, so this tool
7
+ does NOT generate or rewrite chunks and NEVER touches the master prompts.
8
+
9
+ What it does instead — it is a SENTINEL ("сторож"):
10
+
11
+ * keeps a "seal" (hash) of the master section each watched chunk was distilled from,
12
+ * on --check compares the live master section's hash to the seal and FAILS if the
13
+ master section changed but the chunk was not refreshed (catches a stale chunk
14
+ before it ships to prod),
15
+ * on --diff shows what changed in that section (sealed snapshot -> current) plus the
16
+ current chunk, so a human (or Claude) can update the distillation,
17
+ * on --reseal recomputes the seals after chunks were updated.
18
+
19
+ Map (which chunk <- which master section, by heading anchor) lives in
20
+ ``instructions_map.yml`` (human-authored, commented). Seals (machine-written hashes +
21
+ section snapshots) live in ``instructions_seals.json`` so the map keeps its comments.
22
+
23
+ Usage:
24
+ build_instructions.py --check # default; exit 1 on stale / missing anchor / no seal
25
+ build_instructions.py --diff # show master-section change + current chunk
26
+ build_instructions.py --reseal # re-stamp seals after updating chunks
27
+ build_instructions.py --repo PATH # override repo root (default: parent of this file)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import difflib
34
+ import hashlib
35
+ import json
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ import yaml
40
+
41
+ REPO_DEFAULT = Path(__file__).resolve().parent.parent # mcp/ -> repo root
42
+ MAP_REL = "mcp/instructions_map.yml"
43
+ SEALS_REL = "mcp/instructions_seals.json"
44
+
45
+
46
+ # ── section extraction ─────────────────────────────────────────────────────────
47
+ def heading_level(line: str) -> int | None:
48
+ """Markdown ATX heading level (``###`` -> 3), or None if not a heading."""
49
+ s = line.lstrip()
50
+ n = len(s) - len(s.lstrip("#"))
51
+ if n > 0 and s[n : n + 1] == " ":
52
+ return n
53
+ return None
54
+
55
+
56
+ def extract_section(text: str, anchor: str) -> str | None:
57
+ """Return the master section starting at the heading that startswith ``anchor``,
58
+ up to (excluding) the next heading of level <= the anchor's level (or EOF).
59
+ None if the anchor heading is not found."""
60
+ lines = text.splitlines()
61
+ anchor = anchor.strip()
62
+ start = level = None
63
+ for i, ln in enumerate(lines):
64
+ lv = heading_level(ln)
65
+ if lv is not None and ln.strip().startswith(anchor):
66
+ start, level = i, lv
67
+ break
68
+ if start is None:
69
+ return None
70
+ end = len(lines)
71
+ for j in range(start + 1, len(lines)):
72
+ lv = heading_level(lines[j])
73
+ if lv is not None and lv <= level:
74
+ end = j
75
+ break
76
+ return "\n".join(lines[start:end])
77
+
78
+
79
+ def normalize(section: str) -> str:
80
+ """Whitespace-stable form for hashing (ignore trailing spaces / blank edges)."""
81
+ return "\n".join(l.rstrip() for l in section.splitlines()).strip()
82
+
83
+
84
+ def section_sha(section: str) -> str:
85
+ return hashlib.sha256(normalize(section).encode("utf-8")).hexdigest()[:12]
86
+
87
+
88
+ # ── map / seals io ─────────────────────────────────────────────────────────────
89
+ def load_map(repo: Path) -> dict:
90
+ return yaml.safe_load((repo / MAP_REL).read_text(encoding="utf-8")) or {}
91
+
92
+
93
+ def load_seals(repo: Path) -> dict:
94
+ p = repo / SEALS_REL
95
+ if not p.exists():
96
+ return {}
97
+ return json.loads(p.read_text(encoding="utf-8"))
98
+
99
+
100
+ def save_seals(repo: Path, seals: dict) -> None:
101
+ (repo / SEALS_REL).write_text(
102
+ json.dumps(seals, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
103
+ encoding="utf-8",
104
+ )
105
+
106
+
107
+ def iter_watched(cfg: dict):
108
+ """Yield (connector, chunk_dict) for every chunk that is bound to a master section
109
+ (skips ``source: own`` chunks)."""
110
+ for conn, c in (cfg.get("connectors") or {}).items():
111
+ idir = c.get("instructions_dir", "")
112
+ for ch in c.get("chunks") or []:
113
+ yield conn, idir, ch
114
+
115
+
116
+ def seal_key(conn: str, file: str) -> str:
117
+ return f"{conn}/{file}"
118
+
119
+
120
+ # ── live section read (returns (section, error)) ───────────────────────────────
121
+ def read_section(repo: Path, ch: dict):
122
+ master = repo / ch["master"]
123
+ if not master.exists():
124
+ return None, f"master not found: {ch['master']}"
125
+ sec = extract_section(master.read_text(encoding="utf-8"), ch["anchor"])
126
+ if sec is None:
127
+ return None, f'anchor not found in master: "{ch["anchor"]}"'
128
+ return sec, None
129
+
130
+
131
+ # ── tool-description watching ──────────────────────────────────────────────────
132
+ # Guidance lives TWICE by necessity: in the instructions chunks (read by clients
133
+ # that honour MCP `instructions`) and in the tool docstrings (the only channel
134
+ # claude.ai actually reads — issue #43749). These helpers seal the docstrings so
135
+ # a docstring edit forces a conscious review of the md chunks (and vice versa —
136
+ # the chunk edits are caught by the master seals above).
137
+ def iter_tooldesc(cfg: dict):
138
+ """Yield (module_rel_path, function_name) for every watched tool docstring."""
139
+ for entry in cfg.get("tool_descriptions") or []:
140
+ module = entry.get("module", "")
141
+ for fn in entry.get("functions") or []:
142
+ yield module, fn
143
+
144
+
145
+ def read_tool_docstring(repo: Path, module_rel: str, func_name: str):
146
+ """Return (docstring, error) for ``func_name`` in ``module_rel``."""
147
+ import ast
148
+
149
+ module = repo / module_rel
150
+ if not module.exists():
151
+ return None, f"module not found: {module_rel}"
152
+ try:
153
+ tree = ast.parse(module.read_text(encoding="utf-8"))
154
+ except SyntaxError as e:
155
+ return None, f"syntax error in {module_rel}: {e}"
156
+ for node in ast.walk(tree):
157
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
158
+ doc = ast.get_docstring(node)
159
+ if not doc:
160
+ return None, f"{module_rel}:{func_name} has no docstring"
161
+ return doc, None
162
+ return None, f"function not found: {module_rel}:{func_name}"
163
+
164
+
165
+ def tooldesc_key(func_name: str) -> str:
166
+ return f"tooldesc/{func_name}"
167
+
168
+
169
+ # ── commands ───────────────────────────────────────────────────────────────────
170
+ def cmd_check(repo: Path) -> int:
171
+ cfg = load_map(repo)
172
+ seals = load_seals(repo)
173
+ stale, missing, unsealed, ok = [], [], [], []
174
+
175
+ for conn, idir, ch in iter_watched(cfg):
176
+ f = ch["file"]
177
+ chunk_path = repo / idir / f
178
+ if not chunk_path.exists():
179
+ missing.append(f"{conn}/{f}: chunk file missing ({idir}/{f})")
180
+ continue
181
+ if ch.get("source") == "own":
182
+ ok.append(f"{conn}/{f} (own — not watched)")
183
+ continue
184
+
185
+ sec, err = read_section(repo, ch)
186
+ if err:
187
+ missing.append(f"{conn}/{f}: {err}")
188
+ continue
189
+
190
+ key = seal_key(conn, f)
191
+ live = section_sha(sec)
192
+ sealed = (seals.get(key) or {}).get("sha")
193
+ if sealed is None:
194
+ unsealed.append(f"{conn}/{f} (no seal yet — run --reseal)")
195
+ elif sealed != live:
196
+ stale.append(f'{conn}/{f} master section "{ch["anchor"]}" changed '
197
+ f"(sealed {sealed} -> live {live})")
198
+ else:
199
+ ok.append(f"{conn}/{f} (seal {sealed} OK)")
200
+
201
+ for module, fn in iter_tooldesc(cfg):
202
+ doc, err = read_tool_docstring(repo, module, fn)
203
+ if err:
204
+ missing.append(f"tooldesc/{fn}: {err}")
205
+ continue
206
+ key = tooldesc_key(fn)
207
+ live = section_sha(doc)
208
+ sealed = (seals.get(key) or {}).get("sha")
209
+ if sealed is None:
210
+ unsealed.append(f"tooldesc/{fn} (no seal yet — run --reseal)")
211
+ elif sealed != live:
212
+ stale.append(
213
+ f"tooldesc/{fn} tool-description changed (sealed {sealed} -> live "
214
+ f"{live}) — sync the instructions chunks (data/*.md), then --reseal"
215
+ )
216
+ else:
217
+ ok.append(f"tooldesc/{fn} (seal {sealed} OK)")
218
+
219
+ print("=== check:prompts — mcp watchdog ===")
220
+ for line in ok:
221
+ print(f" ✅ {line}")
222
+ for line in unsealed:
223
+ print(f" ⚠️ {line}")
224
+ for line in missing:
225
+ print(f" ❌ {line}")
226
+ for line in stale:
227
+ print(f" ❌ STALE {line}")
228
+
229
+ fail = stale or missing or unsealed
230
+ print()
231
+ if fail:
232
+ print("FAIL — prompt chunks out of sync with master.")
233
+ if stale:
234
+ print(" → review: python3 mcp/build_instructions.py --diff")
235
+ print(" → fix the chunk(s), then: python3 mcp/build_instructions.py --reseal")
236
+ if unsealed:
237
+ print(" → first run: python3 mcp/build_instructions.py --reseal")
238
+ return 1
239
+ print("OK — all watched chunks in sync with master.")
240
+ return 0
241
+
242
+
243
+ def cmd_diff(repo: Path) -> int:
244
+ cfg = load_map(repo)
245
+ seals = load_seals(repo)
246
+ shown = 0
247
+
248
+ for conn, idir, ch in iter_watched(cfg):
249
+ if ch.get("source") == "own":
250
+ continue
251
+ f = ch["file"]
252
+ sec, err = read_section(repo, ch)
253
+ if err:
254
+ print(f"❌ {conn}/{f}: {err}\n")
255
+ shown += 1
256
+ continue
257
+ key = seal_key(conn, f)
258
+ live = section_sha(sec)
259
+ rec = seals.get(key) or {}
260
+ if rec.get("sha") == live:
261
+ continue # in sync — nothing to show
262
+
263
+ shown += 1
264
+ print(f"┌─ {conn}/{f} ← master section \"{ch['anchor']}\"")
265
+ sealed_text = rec.get("section", "")
266
+ if sealed_text:
267
+ print("│ MASTER SECTION CHANGED (sealed → current):")
268
+ d = difflib.unified_diff(
269
+ normalize(sealed_text).splitlines(),
270
+ normalize(sec).splitlines(),
271
+ fromfile="sealed", tofile="current", lineterm="",
272
+ )
273
+ for ln in d:
274
+ print(f"│ {ln}")
275
+ else:
276
+ print("│ (no sealed snapshot yet — current master section:)")
277
+ for ln in normalize(sec).splitlines():
278
+ print(f"│ {ln}")
279
+ chunk_path = repo / idir / f
280
+ print(f"│ CURRENT CHUNK ({idir}/{f}):")
281
+ for ln in chunk_path.read_text(encoding="utf-8").splitlines():
282
+ print(f"│ {ln}")
283
+ print("└─ update the chunk to reflect the master change, then --reseal\n")
284
+
285
+ for module, fn in iter_tooldesc(cfg):
286
+ doc, err = read_tool_docstring(repo, module, fn)
287
+ if err:
288
+ print(f"❌ tooldesc/{fn}: {err}\n")
289
+ shown += 1
290
+ continue
291
+ rec = seals.get(tooldesc_key(fn)) or {}
292
+ if rec.get("sha") == section_sha(doc):
293
+ continue
294
+ shown += 1
295
+ print(f"┌─ tooldesc/{fn} ← docstring in {module}")
296
+ sealed_text = rec.get("docstring", "")
297
+ if sealed_text:
298
+ print("│ TOOL-DESCRIPTION CHANGED (sealed → current):")
299
+ d = difflib.unified_diff(
300
+ normalize(sealed_text).splitlines(),
301
+ normalize(doc).splitlines(),
302
+ fromfile="sealed", tofile="current", lineterm="",
303
+ )
304
+ for ln in d:
305
+ print(f"│ {ln}")
306
+ else:
307
+ print("│ (no sealed snapshot yet — current docstring:)")
308
+ for ln in normalize(doc).splitlines():
309
+ print(f"│ {ln}")
310
+ print("└─ sync the instructions chunks (data/*.md) if needed, then --reseal\n")
311
+
312
+ if shown == 0:
313
+ print("Nothing to diff — all watched chunks in sync.")
314
+ return 0
315
+
316
+
317
+ def cmd_reseal(repo: Path) -> int:
318
+ cfg = load_map(repo)
319
+ seals = load_seals(repo)
320
+ changed = 0
321
+
322
+ for conn, idir, ch in iter_watched(cfg):
323
+ if ch.get("source") == "own":
324
+ continue
325
+ f = ch["file"]
326
+ sec, err = read_section(repo, ch)
327
+ if err:
328
+ print(f" ❌ {conn}/{f}: {err} — NOT sealed")
329
+ continue
330
+ key = seal_key(conn, f)
331
+ sha = section_sha(sec)
332
+ prev = (seals.get(key) or {}).get("sha")
333
+ seals[key] = {"sha": sha, "anchor": ch["anchor"],
334
+ "master": ch["master"], "section": sec}
335
+ if prev != sha:
336
+ changed += 1
337
+ print(f" 🔏 {conn}/{f} {prev or '(new)'} -> {sha}")
338
+ else:
339
+ print(f" • {conn}/{f} {sha} (unchanged)")
340
+
341
+ for module, fn in iter_tooldesc(cfg):
342
+ doc, err = read_tool_docstring(repo, module, fn)
343
+ if err:
344
+ print(f" ❌ tooldesc/{fn}: {err} — NOT sealed")
345
+ continue
346
+ key = tooldesc_key(fn)
347
+ sha = section_sha(doc)
348
+ prev = (seals.get(key) or {}).get("sha")
349
+ seals[key] = {"sha": sha, "module": module, "function": fn, "docstring": doc}
350
+ if prev != sha:
351
+ changed += 1
352
+ print(f" 🔏 tooldesc/{fn} {prev or '(new)'} -> {sha}")
353
+ else:
354
+ print(f" • tooldesc/{fn} {sha} (unchanged)")
355
+
356
+ save_seals(repo, seals)
357
+ print(f"\nSealed {changed} changed chunk(s). Wrote {SEALS_REL}.")
358
+ return 0
359
+
360
+
361
+ def main() -> int:
362
+ ap = argparse.ArgumentParser(description=__doc__,
363
+ formatter_class=argparse.RawDescriptionHelpFormatter)
364
+ g = ap.add_mutually_exclusive_group()
365
+ g.add_argument("--check", action="store_true", help="verify seals (default)")
366
+ g.add_argument("--diff", action="store_true", help="show master change + chunk")
367
+ g.add_argument("--reseal", action="store_true", help="re-stamp seals after fixes")
368
+ ap.add_argument("--repo", default=str(REPO_DEFAULT), help="repo root")
369
+ args = ap.parse_args()
370
+
371
+ repo = Path(args.repo).resolve()
372
+ if not (repo / MAP_REL).exists():
373
+ print(f"FATAL: map not found: {repo / MAP_REL}", file=sys.stderr)
374
+ return 2
375
+
376
+ if args.diff:
377
+ return cmd_diff(repo)
378
+ if args.reseal:
379
+ return cmd_reseal(repo)
380
+ return cmd_check(repo)
381
+
382
+
383
+ if __name__ == "__main__":
384
+ sys.exit(main())