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.
- mcp_ivolatility_data-0.1.5/.dockerignore +5 -0
- mcp_ivolatility_data-0.1.5/.gitignore +8 -0
- mcp_ivolatility_data-0.1.5/.mcpbignore +17 -0
- mcp_ivolatility_data-0.1.5/Dockerfile +29 -0
- mcp_ivolatility_data-0.1.5/LICENSE +21 -0
- mcp_ivolatility_data-0.1.5/PKG-INFO +58 -0
- mcp_ivolatility_data-0.1.5/README.md +72 -0
- mcp_ivolatility_data-0.1.5/README.pypi.md +38 -0
- mcp_ivolatility_data-0.1.5/build_instructions.py +384 -0
- mcp_ivolatility_data-0.1.5/docs/ARCHITECTURE.md +135 -0
- mcp_ivolatility_data-0.1.5/docs/INSTALL.md +131 -0
- mcp_ivolatility_data-0.1.5/docs/USAGE.md +128 -0
- mcp_ivolatility_data-0.1.5/instructions_map.yml +55 -0
- mcp_ivolatility_data-0.1.5/instructions_seals.json +50 -0
- mcp_ivolatility_data-0.1.5/k8s/ivol-mcp.yaml +115 -0
- mcp_ivolatility_data-0.1.5/manifest.json +58 -0
- mcp_ivolatility_data-0.1.5/pyproject.toml +51 -0
- mcp_ivolatility_data-0.1.5/run-data-mcp.sh +13 -0
- mcp_ivolatility_data-0.1.5/scripts/build-mcpb.sh +43 -0
- mcp_ivolatility_data-0.1.5/scripts/stamp-pypi-version.py +33 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/__init__.py +91 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/__main__.py +4 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/assembly.py +50 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/constants.py +201 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/formatters.py +189 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/functions.py +933 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/index.py +660 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/index_openapi.py +219 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/index_prompts.py +158 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/server.py +887 -0
- mcp_ivolatility_data-0.1.5/src/ivol_mcp_core/store.py +1058 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/__init__.py +25 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/__main__.py +4 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/index_supplement.yml +113 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/00-role.md +40 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/10-discovery.md +65 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/10-discovery.stdio.md +5 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/20-access.md +22 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/30-behavior.md +14 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/30-behavior.remote.md +12 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/30-behavior.stdio.md +12 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/instructions/40-calling.md +27 -0
- mcp_ivolatility_data-0.1.5/src/mcp_ivolatility_data/openapi_ivolatility.yml +11277 -0
- mcp_ivolatility_data-0.1.5/tests/__init__.py +0 -0
- mcp_ivolatility_data-0.1.5/tests/fixtures/aggs.md +85 -0
- mcp_ivolatility_data-0.1.5/tests/fixtures/llms-full.txt +13337 -0
- mcp_ivolatility_data-0.1.5/tests/fixtures/llms-partial.txt +833 -0
- mcp_ivolatility_data-0.1.5/tests/fixtures/llms.txt +13 -0
- mcp_ivolatility_data-0.1.5/tests/integration/__init__.py +0 -0
- mcp_ivolatility_data-0.1.5/tests/integration/conftest.py +169 -0
- mcp_ivolatility_data-0.1.5/tests/integration/mock_api_server.py +133 -0
- mcp_ivolatility_data-0.1.5/tests/integration/mock_llms_txt.py +29 -0
- mcp_ivolatility_data-0.1.5/tests/integration/responses.py +321 -0
- mcp_ivolatility_data-0.1.5/tests/integration/test_error_handling.py +176 -0
- mcp_ivolatility_data-0.1.5/tests/integration/test_http_transport.py +145 -0
- mcp_ivolatility_data-0.1.5/tests/integration/test_inprocess.py +314 -0
- mcp_ivolatility_data-0.1.5/tests/integration/test_stdio_transport.py +139 -0
- mcp_ivolatility_data-0.1.5/tests/integration/test_workflows.py +382 -0
- mcp_ivolatility_data-0.1.5/tests/test_assembly.py +51 -0
- mcp_ivolatility_data-0.1.5/tests/test_formatters.py +683 -0
- mcp_ivolatility_data-0.1.5/tests/test_functions.py +1356 -0
- mcp_ivolatility_data-0.1.5/tests/test_index.py +1229 -0
- mcp_ivolatility_data-0.1.5/tests/test_server.py +1158 -0
- mcp_ivolatility_data-0.1.5/tests/test_store.py +3118 -0
- mcp_ivolatility_data-0.1.5/uv.lock +883 -0
|
@@ -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())
|