toolgraph 0.0.1__tar.gz → 0.1.0__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.
- {toolgraph-0.0.1 → toolgraph-0.1.0}/.gitignore +1 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/CHANGELOG.md +74 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/PKG-INFO +20 -4
- {toolgraph-0.0.1 → toolgraph-0.1.0}/README.md +18 -2
- {toolgraph-0.0.1 → toolgraph-0.1.0}/pyproject.toml +1 -1
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/__init__.py +1 -1
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/client.py +31 -14
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/transports.py +6 -4
- toolgraph-0.1.0/toolgraph/errors.py +26 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/driver.py +1 -1
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/queries.py +34 -13
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/selector.py +5 -4
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/quickstart/policy_gateway_server.py +5 -5
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/server/app.py +41 -21
- {toolgraph-0.0.1 → toolgraph-0.1.0}/LICENSE +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/SECURITY.md +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/control-plan.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/control-preflight.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/policy-bundle.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/policy-review-plan.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/preflight.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/review-annotations.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/tracegraph/README.md +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/tracegraph/review-candidates.schema.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/tracegraph/v1.json +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/artifact_safety.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/artifacts.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/cli.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/config.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/control_plan.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/__init__.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/crawl.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/__init__.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/loader.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/schema.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/store.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/manifest/__init__.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/manifest/ingest.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/manifest/parser.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/models.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/policy_bundle.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/policy_review.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/preflight.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/quickstart/governance.yaml +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/quickstart/servers.yaml +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/redaction.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/review_candidates.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/server/__init__.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/uris.py +0 -0
- {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/yaml_loader.py +0 -0
|
@@ -2,6 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Toolgraph are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.0 - 2026-09-10
|
|
6
|
+
|
|
7
|
+
Second alpha. Upgrading from `0.0.1` takes three things: the MCP SDK 2.x, one
|
|
8
|
+
new field for anything caching MCP responses, and — if you generated a
|
|
9
|
+
quickstart with `0.0.1` — a regenerated server script. Read **Upgrading from
|
|
10
|
+
0.0.1** below before installing.
|
|
11
|
+
|
|
12
|
+
### Upgrading from 0.0.1
|
|
13
|
+
|
|
14
|
+
`toolgraph example init` copies a quickstart out of the package, so a
|
|
15
|
+
quickstart generated by `0.0.1` holds its own copy of the demo MCP server —
|
|
16
|
+
one that imports `mcp.server.fastmcp`. Upgrading that environment installs MCP
|
|
17
|
+
2.x and the copied server stops importing, so the next `toolgraph crawl`
|
|
18
|
+
fails. Updating the package does not touch a directory it already wrote, and
|
|
19
|
+
`example init` refuses an existing destination on purpose.
|
|
20
|
+
|
|
21
|
+
To migrate one, generate a fresh quickstart beside it and move your own work
|
|
22
|
+
across:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
toolgraph example init quickstart-0.1.0
|
|
26
|
+
# Keep your authored governance and your database; take the new server script.
|
|
27
|
+
cp quickstart/governance.yaml quickstart-0.1.0/governance.yaml
|
|
28
|
+
cp -R quickstart/.toolgraph quickstart-0.1.0/.toolgraph
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`servers.yaml` records the interpreter that generated it, so regenerating from
|
|
32
|
+
the upgraded environment already points at the right one; check it only if you
|
|
33
|
+
generated from somewhere else. Re-run `toolgraph crawl`, then
|
|
34
|
+
`toolgraph ingest-manifest`, to confirm.
|
|
35
|
+
|
|
36
|
+
Verified end to end against the published `0.0.1`: a quickstart it generated
|
|
37
|
+
crawls cleanly on `0.0.1`, fails on the next crawl once the environment holds
|
|
38
|
+
`0.1.0`, and returns the same eligibility verdicts after these steps.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- `examples/policy_propose_from_observed.py` reads a window of observed gateway
|
|
43
|
+
usage and prints a reviewable `governance.yaml` grant proposal. It never
|
|
44
|
+
writes governance. Repository-only — it is not part of the distribution — and
|
|
45
|
+
deliberately narrow: it requires an envelope declaring the governance digest
|
|
46
|
+
it was recorded against and refuses a mismatch, refuses an export with no
|
|
47
|
+
envelope unless `--unverified-provenance` is given, proposes a removal only
|
|
48
|
+
from an explicit zero-call row, and accepts only fully qualified timestamps
|
|
49
|
+
with an explicit UTC offset. Digest equality shows what the export declared,
|
|
50
|
+
not where its rows came from; issue #89 tracks what a real binding needs.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **Breaking:** requires the MCP SDK 2.x (`mcp>=2.1.1,<3`). Environments pinned
|
|
55
|
+
to `mcp` 1.x can no longer install this package, because 2.x removed
|
|
56
|
+
`mcp.server.fastmcp` and renamed every model field from camelCase to
|
|
57
|
+
snake_case. The MCP *wire* format is unchanged, so a client that only speaks
|
|
58
|
+
the protocol needs no change; that is not the same as no change for anything
|
|
59
|
+
written against the Python SDK, caching responses, or reading exception text.
|
|
60
|
+
- An unexpected exception raised by a toolgraph tool body no longer puts its
|
|
61
|
+
message on the wire. Only errors raised deliberately as `ContractError` (for
|
|
62
|
+
example an unknown selector profile) are forwarded; the rest keep the SDK's
|
|
63
|
+
generic text, so an internal failure cannot disclose filesystem paths, query
|
|
64
|
+
bodies or credential-shaped values. Argument-validation failures still echo
|
|
65
|
+
the caller's own input, as the MCP SDK intends.
|
|
66
|
+
- **Breaking:** an MCP response whose read could not be bracketed by a stable
|
|
67
|
+
graph state now carries `graph_generation: null` and
|
|
68
|
+
`graph_state_verified: false` instead of the generation read afterwards.
|
|
69
|
+
Labelling such a result with a generation it was never read at let a verdict
|
|
70
|
+
from before a permission change be cached under the generation that changed
|
|
71
|
+
it, while the tool documentation tells callers to cache per generation.
|
|
72
|
+
Successful reads gain `graph_state_verified: true`, so the absence of a
|
|
73
|
+
guarantee is never inferred from a missing field. Null is not self-enforcing
|
|
74
|
+
— `None` is a usable cache key — so the obligation to skip the cache and to
|
|
75
|
+
attribute nothing to a graph state is the consumer's; ADR-0004 and the README
|
|
76
|
+
state it, and a consumer that turns eligibility into an authorization
|
|
77
|
+
decision should reject or retry such a response.
|
|
78
|
+
|
|
5
79
|
## 0.0.1 - 2026-09-09
|
|
6
80
|
|
|
7
81
|
Initial public alpha release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolgraph
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Graph-native MCP/tool registry, policy compiler, and impact analyzer
|
|
5
5
|
Project-URL: Homepage, https://github.com/memtomem/toolgraph
|
|
6
6
|
Project-URL: Repository, https://github.com/memtomem/toolgraph
|
|
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
21
21
|
Requires-Python: <3.15,>=3.12
|
|
22
22
|
Requires-Dist: cryptography>=50.0.0
|
|
23
23
|
Requires-Dist: idna>=3.18
|
|
24
|
-
Requires-Dist: mcp<2
|
|
24
|
+
Requires-Dist: mcp<3,>=2.1.1
|
|
25
25
|
Requires-Dist: neo4j<7,>=6.2
|
|
26
26
|
Requires-Dist: pydantic-settings>=2.14.2
|
|
27
27
|
Requires-Dist: pydantic<3,>=2.7
|
|
@@ -493,7 +493,23 @@ server-qualified key. An unknown agent returns `agent_found: false`
|
|
|
493
493
|
a typo'd agent is a context-construction error, not an empty catalog.
|
|
494
494
|
|
|
495
495
|
MCP responses carry `graph_generation` (ADR-0004) so consumers can cache
|
|
496
|
-
features per graph state
|
|
496
|
+
features per graph state. Cache identity is the pair (`graph_instance_id`,
|
|
497
|
+
`graph_generation`), not the integer alone: generations restart with the graph.
|
|
498
|
+
|
|
499
|
+
A response also carries `graph_state_verified`. It is `true` when the read was
|
|
500
|
+
bracketed by a stable graph state, and `false` when the graph moved under every
|
|
501
|
+
retry — in which case all three state fields are null:
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
{"graph_generation": null, "graph_instance_id": null,
|
|
505
|
+
"graph_state": null, "graph_state_verified": false}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
An unverified response is still the answer to your question, but it must not be
|
|
509
|
+
cached and must not be attributed to a graph state. Nothing enforces that for
|
|
510
|
+
you: `null` is a usable dictionary key and two unverified responses compare
|
|
511
|
+
equal on it. A consumer that turns eligibility into an authorization decision
|
|
512
|
+
should reject or retry rather than proceed on one.
|
|
497
513
|
|
|
498
514
|
If the graph backend is temporarily unavailable, every MCP tool returns
|
|
499
515
|
`isError: true` with a typed `structuredContent` envelope instead of exposing
|
|
@@ -620,7 +636,7 @@ toolgraph/
|
|
|
620
636
|
crawler/ connect to MCP servers (stdio + streamable-http), enumerate tools/resources
|
|
621
637
|
graph/ Neo4j driver, schema/constraints, idempotent loader, governance/audit queries
|
|
622
638
|
manifest/ parse + ingest authored governance (idempotent)
|
|
623
|
-
server/
|
|
639
|
+
server/ MCP server exposing the queries as MCP tools
|
|
624
640
|
cli.py crawl / ingest-manifest / check-access / unsafe-tools / blast-radius /
|
|
625
641
|
unmapped-tools / orphan-policies / unbacked-edges / drift /
|
|
626
642
|
destructive-unsafeguarded / annotation-contradictions /
|
|
@@ -456,7 +456,23 @@ server-qualified key. An unknown agent returns `agent_found: false`
|
|
|
456
456
|
a typo'd agent is a context-construction error, not an empty catalog.
|
|
457
457
|
|
|
458
458
|
MCP responses carry `graph_generation` (ADR-0004) so consumers can cache
|
|
459
|
-
features per graph state
|
|
459
|
+
features per graph state. Cache identity is the pair (`graph_instance_id`,
|
|
460
|
+
`graph_generation`), not the integer alone: generations restart with the graph.
|
|
461
|
+
|
|
462
|
+
A response also carries `graph_state_verified`. It is `true` when the read was
|
|
463
|
+
bracketed by a stable graph state, and `false` when the graph moved under every
|
|
464
|
+
retry — in which case all three state fields are null:
|
|
465
|
+
|
|
466
|
+
```json
|
|
467
|
+
{"graph_generation": null, "graph_instance_id": null,
|
|
468
|
+
"graph_state": null, "graph_state_verified": false}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
An unverified response is still the answer to your question, but it must not be
|
|
472
|
+
cached and must not be attributed to a graph state. Nothing enforces that for
|
|
473
|
+
you: `null` is a usable dictionary key and two unverified responses compare
|
|
474
|
+
equal on it. A consumer that turns eligibility into an authorization decision
|
|
475
|
+
should reject or retry rather than proceed on one.
|
|
460
476
|
|
|
461
477
|
If the graph backend is temporarily unavailable, every MCP tool returns
|
|
462
478
|
`isError: true` with a typed `structuredContent` envelope instead of exposing
|
|
@@ -583,7 +599,7 @@ toolgraph/
|
|
|
583
599
|
crawler/ connect to MCP servers (stdio + streamable-http), enumerate tools/resources
|
|
584
600
|
graph/ Neo4j driver, schema/constraints, idempotent loader, governance/audit queries
|
|
585
601
|
manifest/ parse + ingest authored governance (idempotent)
|
|
586
|
-
server/
|
|
602
|
+
server/ MCP server exposing the queries as MCP tools
|
|
587
603
|
cli.py crawl / ingest-manifest / check-access / unsafe-tools / blast-radius /
|
|
588
604
|
unmapped-tools / orphan-policies / unbacked-edges / drift /
|
|
589
605
|
destructive-unsafeguarded / annotation-contradictions /
|
|
@@ -19,6 +19,23 @@ class PaginationLimitError(RuntimeError):
|
|
|
19
19
|
"""A server kept returning continuation cursors past MAX_LIST_PAGES."""
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _page(cursor: str | None):
|
|
23
|
+
"""Pagination params for a list call, or None for the first page.
|
|
24
|
+
|
|
25
|
+
mcp 2.x replaced the ``cursor=`` keyword with a params object. The SDK
|
|
26
|
+
treats "no params" and "params with a null cursor" as equivalent when
|
|
27
|
+
deciding a listing is complete, so returning None here is a matter of
|
|
28
|
+
sending the smaller request, not of correctness. The distinction that DOES
|
|
29
|
+
decide the loop is null versus ``""`` in the RESPONSE cursor, which the
|
|
30
|
+
caller below handles.
|
|
31
|
+
"""
|
|
32
|
+
if cursor is None:
|
|
33
|
+
return None
|
|
34
|
+
from mcp.types import PaginatedRequestParams
|
|
35
|
+
|
|
36
|
+
return PaginatedRequestParams(cursor=cursor)
|
|
37
|
+
|
|
38
|
+
|
|
22
39
|
def _annotation_fields(tool) -> dict:
|
|
23
40
|
"""Tool annotations as ToolRecord fields (ADR-0006).
|
|
24
41
|
|
|
@@ -30,15 +47,15 @@ def _annotation_fields(tool) -> dict:
|
|
|
30
47
|
if ann is None:
|
|
31
48
|
return {}
|
|
32
49
|
return {
|
|
33
|
-
"read_only_hint": ann.
|
|
34
|
-
"destructive_hint": ann.
|
|
35
|
-
"idempotent_hint": ann.
|
|
36
|
-
"open_world_hint": ann.
|
|
50
|
+
"read_only_hint": ann.read_only_hint,
|
|
51
|
+
"destructive_hint": ann.destructive_hint,
|
|
52
|
+
"idempotent_hint": ann.idempotent_hint,
|
|
53
|
+
"open_world_hint": ann.open_world_hint,
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
|
|
40
57
|
async def _list_all_tools(session) -> list[ToolRecord]:
|
|
41
|
-
"""Drain ``list_tools`` pages until ``
|
|
58
|
+
"""Drain ``list_tools`` pages until ``next_cursor`` is exhausted.
|
|
42
59
|
|
|
43
60
|
Without pagination, a server with more than one page silently drops
|
|
44
61
|
tools on later pages — governance refs to them would then reject at
|
|
@@ -48,17 +65,17 @@ async def _list_all_tools(session) -> list[ToolRecord]:
|
|
|
48
65
|
out: list[ToolRecord] = []
|
|
49
66
|
cursor: str | None = None
|
|
50
67
|
for _ in range(MAX_LIST_PAGES):
|
|
51
|
-
listed = await session.list_tools(
|
|
68
|
+
listed = await session.list_tools(params=_page(cursor))
|
|
52
69
|
out.extend(
|
|
53
70
|
ToolRecord(
|
|
54
71
|
name=t.name,
|
|
55
72
|
description=t.description,
|
|
56
|
-
input_schema=t.
|
|
73
|
+
input_schema=t.input_schema,
|
|
57
74
|
**_annotation_fields(t),
|
|
58
75
|
)
|
|
59
76
|
for t in listed.tools
|
|
60
77
|
)
|
|
61
|
-
cursor = getattr(listed, "
|
|
78
|
+
cursor = getattr(listed, "next_cursor", None)
|
|
62
79
|
if cursor is None:
|
|
63
80
|
# Stop ONLY on None: an empty-string cursor is a valid opaque
|
|
64
81
|
# continuation token in MCP and the SDK distinguishes "" from
|
|
@@ -72,21 +89,21 @@ async def _list_all_tools(session) -> list[ToolRecord]:
|
|
|
72
89
|
|
|
73
90
|
|
|
74
91
|
async def _list_all_resources(session) -> list[ResourceRecord]:
|
|
75
|
-
"""Drain ``list_resources`` pages until ``
|
|
92
|
+
"""Drain ``list_resources`` pages until ``next_cursor`` is exhausted."""
|
|
76
93
|
out: list[ResourceRecord] = []
|
|
77
94
|
cursor: str | None = None
|
|
78
95
|
for _ in range(MAX_LIST_PAGES):
|
|
79
|
-
listed = await session.list_resources(
|
|
96
|
+
listed = await session.list_resources(params=_page(cursor))
|
|
80
97
|
out.extend(
|
|
81
98
|
ResourceRecord(
|
|
82
99
|
uri=normalize_resource_uri(str(r.uri)),
|
|
83
100
|
name=r.name,
|
|
84
|
-
mime_type=r.
|
|
101
|
+
mime_type=r.mime_type,
|
|
85
102
|
description=r.description,
|
|
86
103
|
)
|
|
87
104
|
for r in listed.resources
|
|
88
105
|
)
|
|
89
|
-
cursor = getattr(listed, "
|
|
106
|
+
cursor = getattr(listed, "next_cursor", None)
|
|
90
107
|
if cursor is None:
|
|
91
108
|
# Stop ONLY on None: an empty-string cursor is a valid opaque
|
|
92
109
|
# continuation token in MCP and the SDK distinguishes "" from
|
|
@@ -111,8 +128,8 @@ async def crawl_server(spec: ServerSpec) -> CrawlResult:
|
|
|
111
128
|
)
|
|
112
129
|
|
|
113
130
|
return CrawlResult(
|
|
114
|
-
server_name=spec.name or init.
|
|
115
|
-
server_version=init.
|
|
131
|
+
server_name=spec.name or init.server_info.name,
|
|
132
|
+
server_version=init.server_info.version,
|
|
116
133
|
transport=spec.transport,
|
|
117
134
|
endpoint=endpoint_label(spec),
|
|
118
135
|
tools=tools,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Open an MCP ClientSession over the right transport for a ServerSpec.
|
|
2
2
|
|
|
3
|
-
Normalizes the transport differences the SDK exposes
|
|
4
|
-
``(read, write)
|
|
5
|
-
|
|
3
|
+
Normalizes the transport differences the SDK exposes. Under mcp 2.x every
|
|
4
|
+
transport yields the same 2-tuple ``(read, write)``; the session-id getter
|
|
5
|
+
streamable-http used to return as a third element is gone. SSE is supported
|
|
6
|
+
only as a deprecated fallback.
|
|
6
7
|
|
|
7
8
|
Uses the preferred ``streamable_http_client``; since it takes headers via a
|
|
8
9
|
custom httpx client rather than a ``headers=`` kwarg, we build one with the
|
|
@@ -42,7 +43,8 @@ async def open_session(spec: ServerSpec) -> AsyncIterator[ClientSession]:
|
|
|
42
43
|
http_client = await stack.enter_async_context(
|
|
43
44
|
create_mcp_http_client(headers=spec.headers)
|
|
44
45
|
)
|
|
45
|
-
|
|
46
|
+
# mcp 2.x drops the session-id getter from the returned streams.
|
|
47
|
+
read, write = await stack.enter_async_context(
|
|
46
48
|
streamable_http_client(spec.url, http_client=http_client)
|
|
47
49
|
)
|
|
48
50
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Errors whose message is safe to hand back to a caller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContractError(ValueError):
|
|
7
|
+
"""A caller-fixable violation of a documented contract.
|
|
8
|
+
|
|
9
|
+
The distinction is load-bearing at the MCP boundary. mcp 2.x masks the text
|
|
10
|
+
of an unexpected exception so an internal failure cannot leak filesystem
|
|
11
|
+
paths, query bodies or credential-shaped values to a client. That default is
|
|
12
|
+
right, and toolgraph keeps it for everything except the errors it raises
|
|
13
|
+
deliberately here.
|
|
14
|
+
|
|
15
|
+
A ContractError message is built only from values the caller supplied and
|
|
16
|
+
fixed constants -- "unknown profile 'production', expected one of [...]" --
|
|
17
|
+
so forwarding it is the answer the caller needs rather than a disclosure.
|
|
18
|
+
|
|
19
|
+
Raise this ONLY where that is true. Never use it to wrap a driver,
|
|
20
|
+
filesystem, or configuration failure: those carry text nobody audited, and
|
|
21
|
+
``redact_text`` models URLs and a handful of credential labels, not
|
|
22
|
+
arbitrary confidential strings.
|
|
23
|
+
|
|
24
|
+
It subclasses ValueError so existing ``except ValueError`` callers and the
|
|
25
|
+
CLI's error handling keep working unchanged.
|
|
26
|
+
"""
|
|
@@ -73,7 +73,7 @@ def _translating_backend_errors() -> Iterator[None]:
|
|
|
73
73
|
def is_backend_unavailable(exc: BaseException) -> bool:
|
|
74
74
|
"""Whether *exc* was caused by a backend outage typed at the driver seam.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
MCPServer wraps tool exceptions, so walk the chain. ``__context__`` is
|
|
77
77
|
followed only when Python did not suppress it: a deliberate
|
|
78
78
|
``raise ... from None`` while handling an outage is the author saying the
|
|
79
79
|
outer error is the real one, and must not be reclassified as retryable.
|
|
@@ -58,19 +58,37 @@ def with_generation(fetch: Callable[[], dict], *, strict: bool = False) -> dict:
|
|
|
58
58
|
Neo4j is read-committed rather than snapshot-isolated. Reading the
|
|
59
59
|
generation before and after the query and retrying on a change prevents a
|
|
60
60
|
result from being labelled with a generation that committed midway
|
|
61
|
-
through it.
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
through it.
|
|
62
|
+
|
|
63
|
+
Persisted artifact producers pass ``strict=True`` and fail rather than
|
|
64
|
+
write a mislabelled artifact. A live query still returns its data after
|
|
65
|
+
retry exhaustion, because an answer under churn beats an error, but it
|
|
66
|
+
comes back with ``graph_generation: None`` and
|
|
67
|
+
``graph_state_verified: False``. Stamping the generation read AFTERWARDS
|
|
68
|
+
labelled the result with a state it was never read at, and the MCP tools
|
|
69
|
+
tell callers to cache per generation -- so a verdict from before a
|
|
70
|
+
permission change could be cached under the very generation that changed
|
|
71
|
+
it.
|
|
72
|
+
|
|
73
|
+
Null does not enforce anything by itself: ``None`` is a usable dict key and
|
|
74
|
+
two unverified responses compare equal on it. What changes is that the
|
|
75
|
+
response no longer ASSERTS a state it never had, and says so in a field a
|
|
76
|
+
consumer can act on. The obligation to skip the cache is the consumer's;
|
|
77
|
+
ADR-0004 states it.
|
|
64
78
|
"""
|
|
65
79
|
for attempt in range(_GENERATION_RETRIES):
|
|
66
80
|
before = graph_generation()
|
|
67
81
|
result = fetch()
|
|
68
82
|
if graph_generation() == before:
|
|
69
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
**result,
|
|
85
|
+
"graph_generation": before,
|
|
86
|
+
"graph_state_verified": True,
|
|
87
|
+
}
|
|
70
88
|
_bracket_backoff(attempt)
|
|
71
89
|
if strict:
|
|
72
90
|
raise RuntimeError("graph generation changed during every read attempt")
|
|
73
|
-
return {**result, "graph_generation":
|
|
91
|
+
return {**result, "graph_generation": None, "graph_state_verified": False}
|
|
74
92
|
|
|
75
93
|
|
|
76
94
|
def with_graph_state(
|
|
@@ -79,7 +97,12 @@ def with_graph_state(
|
|
|
79
97
|
strict: bool = False,
|
|
80
98
|
state_reader: Callable[[], GraphState] | None = None,
|
|
81
99
|
) -> dict:
|
|
82
|
-
"""Return ``fetch`` bracketed by the collision-safe graph state token.
|
|
100
|
+
"""Return ``fetch`` bracketed by the collision-safe graph state token.
|
|
101
|
+
|
|
102
|
+
Same contract as ``with_generation``: an unbracketed read comes back with
|
|
103
|
+
every state field null and ``graph_state_verified: False`` rather than
|
|
104
|
+
carrying a token it was not read under.
|
|
105
|
+
"""
|
|
83
106
|
read_state = state_reader if state_reader is not None else graph_state
|
|
84
107
|
for attempt in range(_GENERATION_RETRIES):
|
|
85
108
|
before = read_state()
|
|
@@ -91,19 +114,17 @@ def with_graph_state(
|
|
|
91
114
|
"graph_generation": before.generation,
|
|
92
115
|
"graph_instance_id": before.instance_id,
|
|
93
116
|
"graph_state": token,
|
|
117
|
+
"graph_state_verified": True,
|
|
94
118
|
}
|
|
95
119
|
_bracket_backoff(attempt)
|
|
96
120
|
if strict:
|
|
97
121
|
raise RuntimeError("graph state changed during every read attempt")
|
|
98
|
-
current = read_state()
|
|
99
122
|
return {
|
|
100
123
|
**result,
|
|
101
|
-
"graph_generation":
|
|
102
|
-
"graph_instance_id":
|
|
103
|
-
"graph_state":
|
|
104
|
-
|
|
105
|
-
"generation": current.generation,
|
|
106
|
-
},
|
|
124
|
+
"graph_generation": None,
|
|
125
|
+
"graph_instance_id": None,
|
|
126
|
+
"graph_state": None,
|
|
127
|
+
"graph_state_verified": False,
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
|
|
@@ -21,6 +21,7 @@ consumer may never override a hard reject (NOT_GRANTED, violation, drifted).
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
from toolgraph.errors import ContractError
|
|
24
25
|
from toolgraph.graph.driver import session
|
|
25
26
|
from toolgraph.graph.queries import _deny_evidence_batch, agent_exists, resolve_tool_refs
|
|
26
27
|
|
|
@@ -164,7 +165,7 @@ def rank_features(agent: str, candidates: list[str]) -> dict:
|
|
|
164
165
|
``risk_score`` (see ``_risk_score`` for the fixed table).
|
|
165
166
|
"""
|
|
166
167
|
if len(candidates) > MAX_CANDIDATES:
|
|
167
|
-
raise
|
|
168
|
+
raise ContractError(
|
|
168
169
|
f"{len(candidates)} candidates exceed the {MAX_CANDIDATES} limit"
|
|
169
170
|
)
|
|
170
171
|
if not agent_exists(agent):
|
|
@@ -274,7 +275,7 @@ def filter_features(ranked: dict, profile: str = DEFAULT_PROFILE) -> dict:
|
|
|
274
275
|
graph evaluation twice inside the generation-retry bracket.
|
|
275
276
|
"""
|
|
276
277
|
if profile not in PROFILES:
|
|
277
|
-
raise
|
|
278
|
+
raise ContractError(
|
|
278
279
|
f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
|
|
279
280
|
)
|
|
280
281
|
agent = ranked["agent"]
|
|
@@ -336,7 +337,7 @@ def eligible_tools(
|
|
|
336
337
|
``eligible`` but may never resurrect a rejected row.
|
|
337
338
|
"""
|
|
338
339
|
if profile not in PROFILES:
|
|
339
|
-
raise
|
|
340
|
+
raise ContractError(
|
|
340
341
|
f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
|
|
341
342
|
)
|
|
342
343
|
return filter_features(rank_features(agent, candidates), profile)
|
|
@@ -348,7 +349,7 @@ def selection_explain(
|
|
|
348
349
|
"""Compact human-readable reasons for one (agent, tool) — a view over the
|
|
349
350
|
same facts ``rank_features`` returns, for operators and end users."""
|
|
350
351
|
if profile not in PROFILES:
|
|
351
|
-
raise
|
|
352
|
+
raise ContractError(
|
|
352
353
|
f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
|
|
353
354
|
)
|
|
354
355
|
ranked = rank_features(agent, [tool])
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from mcp.server.
|
|
5
|
+
from mcp.server.mcpserver import MCPServer
|
|
6
6
|
from mcp.types import ToolAnnotations
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def build() ->
|
|
10
|
-
server =
|
|
9
|
+
def build() -> MCPServer:
|
|
10
|
+
server = MCPServer("policy-gateway")
|
|
11
11
|
|
|
12
|
-
@server.tool(annotations=ToolAnnotations(
|
|
12
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True))
|
|
13
13
|
def read_note(note_id: str) -> str:
|
|
14
14
|
"""Read a deterministic demo note."""
|
|
15
15
|
return f"note:{note_id}"
|
|
16
16
|
|
|
17
|
-
@server.tool(annotations=ToolAnnotations(
|
|
17
|
+
@server.tool(annotations=ToolAnnotations(destructive_hint=True))
|
|
18
18
|
def publish_note(note_id: str, content: str) -> str:
|
|
19
19
|
"""Publish a demo note to the governed draft area."""
|
|
20
20
|
return f"published:{note_id}:{len(content)}"
|
|
@@ -4,18 +4,25 @@ it crawls. Thin wrappers over graph.queries — all logic lives there.
|
|
|
4
4
|
Every response carries ``graph_generation`` (ADR-0004) so callers can cache
|
|
5
5
|
graph-derived features and pin telemetry to a replayable graph state. The
|
|
6
6
|
stamp lives HERE, not in the query layer — CLI output stays unchanged.
|
|
7
|
+
|
|
8
|
+
``graph_generation`` is null, with ``graph_state_verified: false``, when the
|
|
9
|
+
graph moved under every read attempt. The data is still the answer; what is
|
|
10
|
+
missing is a state it was demonstrably read at. Such a response must not be
|
|
11
|
+
cached and must not be attributed to a graph state — nothing enforces that on
|
|
12
|
+
the consumer's behalf, so ADR-0004 states the obligation. A consumer turning
|
|
13
|
+
eligibility into an authorization decision should reject or retry instead.
|
|
7
14
|
"""
|
|
8
15
|
|
|
9
16
|
from __future__ import annotations
|
|
10
17
|
|
|
11
|
-
from collections.abc import Sequence
|
|
12
18
|
import logging
|
|
13
19
|
from typing import Any
|
|
14
20
|
|
|
15
|
-
from mcp.server.
|
|
16
|
-
from mcp.server.
|
|
17
|
-
from mcp.types import CallToolResult,
|
|
21
|
+
from mcp.server.mcpserver import MCPServer
|
|
22
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
23
|
+
from mcp.types import CallToolResult, TextContent, ToolAnnotations
|
|
18
24
|
|
|
25
|
+
from toolgraph.errors import ContractError
|
|
19
26
|
from toolgraph.graph import queries, selector
|
|
20
27
|
from toolgraph.graph.driver import is_backend_unavailable
|
|
21
28
|
from toolgraph.redaction import redact_text
|
|
@@ -28,8 +35,8 @@ _BACKEND_UNAVAILABLE_MESSAGE = (
|
|
|
28
35
|
)
|
|
29
36
|
|
|
30
37
|
|
|
31
|
-
class _ToolgraphMCP(
|
|
32
|
-
"""
|
|
38
|
+
class _ToolgraphMCP(MCPServer):
|
|
39
|
+
"""MCPServer with a typed envelope for backend availability only."""
|
|
33
40
|
|
|
34
41
|
def read_only_tool(self, name: str | None = None, **kwargs: Any):
|
|
35
42
|
"""Register a pure-read graph tool: safe to retry, no added side effect.
|
|
@@ -43,7 +50,7 @@ class _ToolgraphMCP(FastMCP):
|
|
|
43
50
|
def decorator(fn):
|
|
44
51
|
return self.tool(
|
|
45
52
|
name=name,
|
|
46
|
-
annotations=ToolAnnotations(
|
|
53
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
47
54
|
**kwargs,
|
|
48
55
|
)(fn)
|
|
49
56
|
|
|
@@ -54,7 +61,7 @@ class _ToolgraphMCP(FastMCP):
|
|
|
54
61
|
|
|
55
62
|
Read from the annotations the server actually advertises, so there is
|
|
56
63
|
exactly one source of truth. A parallel registry could disagree with
|
|
57
|
-
the live registration —
|
|
64
|
+
the live registration — MCPServer keeps the *first* tool on a duplicate
|
|
58
65
|
name, so a write tool could otherwise inherit a later read-only claim.
|
|
59
66
|
|
|
60
67
|
Every failure path answers "no": an unknown tool, an unannotated tool,
|
|
@@ -70,7 +77,7 @@ class _ToolgraphMCP(FastMCP):
|
|
|
70
77
|
try:
|
|
71
78
|
for tool in await self.list_tools():
|
|
72
79
|
if tool.name == name:
|
|
73
|
-
return bool(tool.annotations and tool.annotations.
|
|
80
|
+
return bool(tool.annotations and tool.annotations.read_only_hint)
|
|
74
81
|
except Exception:
|
|
75
82
|
logger.warning(
|
|
76
83
|
"could not read retry safety for tool %r; reporting not retryable",
|
|
@@ -80,19 +87,30 @@ class _ToolgraphMCP(FastMCP):
|
|
|
80
87
|
return False
|
|
81
88
|
|
|
82
89
|
async def call_tool(
|
|
83
|
-
self, name: str, arguments: dict[str, Any]
|
|
84
|
-
) ->
|
|
90
|
+
self, name: str, arguments: dict[str, Any], context: Any = None
|
|
91
|
+
) -> CallToolResult | Any:
|
|
92
|
+
# mcp 2.x threads a Context through and narrows the return to a typed
|
|
93
|
+
# result; 1.x returned a loose union the low-level server converted.
|
|
85
94
|
try:
|
|
86
|
-
return await super().call_tool(name, arguments)
|
|
95
|
+
return await super().call_tool(name, arguments, context)
|
|
87
96
|
except ToolError as exc:
|
|
88
97
|
if not is_backend_unavailable(exc):
|
|
89
98
|
# The seam types every outage it covers, so reaching here means
|
|
90
|
-
# either a contract error or
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
99
|
+
# either a contract error or an exception that never passed the
|
|
100
|
+
# seam.
|
|
101
|
+
#
|
|
102
|
+
# mcp 2.x masks unexpected exception text, and that default is
|
|
103
|
+
# kept. Only ContractError is forwarded: its message is built
|
|
104
|
+
# from caller-supplied values and fixed constants, so "unknown
|
|
105
|
+
# profile 'production'" reaches the caller who can act on it.
|
|
106
|
+
# Everything else re-raises untouched, which keeps the mask and
|
|
107
|
+
# preserves the SDK's crash-path logging for an
|
|
108
|
+
# UnexpectedToolError. redact_text models URLs and a few
|
|
109
|
+
# credential labels, not arbitrary confidential text, so it is
|
|
110
|
+
# a second line of defence here, not the reason this is safe.
|
|
111
|
+
if isinstance(exc.__cause__, ContractError):
|
|
112
|
+
raise ToolError(redact_text(exc.__cause__)) from exc
|
|
113
|
+
raise
|
|
96
114
|
payload: dict[str, Any] = {
|
|
97
115
|
"error_kind": "backend_unavailable",
|
|
98
116
|
"retryable": await self._is_retryable(name),
|
|
@@ -100,8 +118,8 @@ class _ToolgraphMCP(FastMCP):
|
|
|
100
118
|
}
|
|
101
119
|
return CallToolResult(
|
|
102
120
|
content=[TextContent(type="text", text=_BACKEND_UNAVAILABLE_MESSAGE)],
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
structured_content=payload,
|
|
122
|
+
is_error=True,
|
|
105
123
|
)
|
|
106
124
|
|
|
107
125
|
|
|
@@ -267,7 +285,9 @@ def rank_features(agent: str, candidates: list[str]) -> dict:
|
|
|
267
285
|
four annotation self-claims — plus the rule-based ``risk_score`` from the
|
|
268
286
|
published fixed table. No relevance, no learning. ``agent_found: false``
|
|
269
287
|
means selection should abort: context-construction error, not an empty
|
|
270
|
-
result. Cache
|
|
288
|
+
result. Cache on (``graph_instance_id``, ``graph_generation``), and never
|
|
289
|
+
when ``graph_state_verified`` is false — that read could not be bracketed
|
|
290
|
+
by a stable graph state, so it belongs to no state at all.
|
|
271
291
|
"""
|
|
272
292
|
return _with_generation(lambda: selector.rank_features(agent, candidates))
|
|
273
293
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|