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.
Files changed (50) hide show
  1. {toolgraph-0.0.1 → toolgraph-0.1.0}/.gitignore +1 -0
  2. {toolgraph-0.0.1 → toolgraph-0.1.0}/CHANGELOG.md +74 -0
  3. {toolgraph-0.0.1 → toolgraph-0.1.0}/PKG-INFO +20 -4
  4. {toolgraph-0.0.1 → toolgraph-0.1.0}/README.md +18 -2
  5. {toolgraph-0.0.1 → toolgraph-0.1.0}/pyproject.toml +1 -1
  6. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/__init__.py +1 -1
  7. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/client.py +31 -14
  8. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/transports.py +6 -4
  9. toolgraph-0.1.0/toolgraph/errors.py +26 -0
  10. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/driver.py +1 -1
  11. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/queries.py +34 -13
  12. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/selector.py +5 -4
  13. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/quickstart/policy_gateway_server.py +5 -5
  14. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/server/app.py +41 -21
  15. {toolgraph-0.0.1 → toolgraph-0.1.0}/LICENSE +0 -0
  16. {toolgraph-0.0.1 → toolgraph-0.1.0}/SECURITY.md +0 -0
  17. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/control-plan.schema.json +0 -0
  18. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/control-preflight.schema.json +0 -0
  19. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/policy-bundle.schema.json +0 -0
  20. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/policy-review-plan.schema.json +0 -0
  21. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/preflight.schema.json +0 -0
  22. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/review-annotations.schema.json +0 -0
  23. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/tracegraph/README.md +0 -0
  24. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/tracegraph/review-candidates.schema.json +0 -0
  25. {toolgraph-0.0.1 → toolgraph-0.1.0}/contracts/tracegraph/v1.json +0 -0
  26. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/artifact_safety.py +0 -0
  27. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/artifacts.py +0 -0
  28. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/cli.py +0 -0
  29. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/config.py +0 -0
  30. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/control_plan.py +0 -0
  31. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/__init__.py +0 -0
  32. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/crawler/crawl.py +0 -0
  33. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/__init__.py +0 -0
  34. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/loader.py +0 -0
  35. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/schema.py +0 -0
  36. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/graph/store.py +0 -0
  37. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/manifest/__init__.py +0 -0
  38. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/manifest/ingest.py +0 -0
  39. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/manifest/parser.py +0 -0
  40. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/models.py +0 -0
  41. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/policy_bundle.py +0 -0
  42. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/policy_review.py +0 -0
  43. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/preflight.py +0 -0
  44. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/quickstart/governance.yaml +0 -0
  45. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/quickstart/servers.yaml +0 -0
  46. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/redaction.py +0 -0
  47. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/review_candidates.py +0 -0
  48. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/server/__init__.py +0 -0
  49. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/uris.py +0 -0
  50. {toolgraph-0.0.1 → toolgraph-0.1.0}/toolgraph/yaml_loader.py +0 -0
@@ -15,6 +15,7 @@ wheels/
15
15
 
16
16
  # Agent session logs
17
17
  .swarm/
18
+ .dev-trio/
18
19
 
19
20
  # macOS / local git worktrees
20
21
  .DS_Store
@@ -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.1
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,>=1.28.1
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 with one integer comparison.
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/ FastMCP server exposing the queries as MCP tools
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 with one integer comparison.
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/ FastMCP server exposing the queries as MCP tools
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 /
@@ -28,7 +28,7 @@ dependencies = [
28
28
  "python-multipart>=0.0.31",
29
29
  "starlette>=1.3.1",
30
30
  "pydantic-settings>=2.14.2",
31
- "mcp>=1.28.1,<2",
31
+ "mcp>=2.1.1,<3",
32
32
  "neo4j>=6.2,<7",
33
33
  "pydantic>=2.7,<3",
34
34
  "pyyaml>=6.0",
@@ -1,3 +1,3 @@
1
1
  """toolgraph — graph-native MCP/tool registry for advisory governance analysis."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.1.0"
@@ -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.readOnlyHint,
34
- "destructive_hint": ann.destructiveHint,
35
- "idempotent_hint": ann.idempotentHint,
36
- "open_world_hint": ann.openWorldHint,
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 ``nextCursor`` is exhausted.
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(cursor=cursor)
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.inputSchema,
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, "nextCursor", None)
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 ``nextCursor`` is exhausted."""
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(cursor=cursor)
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.mimeType,
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, "nextCursor", None)
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.serverInfo.name,
115
- server_version=init.serverInfo.version,
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: stdio yields a 2-tuple
4
- ``(read, write)`` while streamable-http yields a 3-tuple
5
- ``(read, write, get_session_id)``. SSE is supported only as a deprecated fallback.
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
- read, write, _get_session_id = await stack.enter_async_context(
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
- FastMCP wraps tool exceptions, so walk the chain. ``__context__`` is
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. MCP live queries keep their historical self-healing fallback
62
- after retry exhaustion; persisted artifact producers pass ``strict=True``
63
- so they fail instead of writing a potentially mislabelled artifact.
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 {**result, "graph_generation": before}
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": 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": current.generation,
102
- "graph_instance_id": current.instance_id,
103
- "graph_state": {
104
- "instance_id": current.instance_id,
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 ValueError(
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 ValueError(
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 ValueError(
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 ValueError(
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.fastmcp import FastMCP
5
+ from mcp.server.mcpserver import MCPServer
6
6
  from mcp.types import ToolAnnotations
7
7
 
8
8
 
9
- def build() -> FastMCP:
10
- server = FastMCP("policy-gateway")
9
+ def build() -> MCPServer:
10
+ server = MCPServer("policy-gateway")
11
11
 
12
- @server.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True))
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(destructiveHint=True))
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.fastmcp import FastMCP
16
- from mcp.server.fastmcp.exceptions import ToolError
17
- from mcp.types import CallToolResult, ContentBlock, TextContent, ToolAnnotations
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(FastMCP):
32
- """FastMCP server with a typed envelope for backend availability only."""
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(readOnlyHint=True, idempotentHint=True),
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 — FastMCP keeps the *first* tool on a duplicate
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.readOnlyHint)
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
- ) -> Sequence[ContentBlock] | dict[str, Any] | CallToolResult:
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 a driver exception that never
91
- # passed the seam. FastMCP puts this message on the wire
92
- # verbatim, and driver text can carry a connection URI, so it
93
- # is scrubbed before it leaves. Ordinary contract text is
94
- # unaffected; the cause is kept for server-side logs.
95
- raise ToolError(redact_text(exc)) from exc
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
- structuredContent=payload,
104
- isError=True,
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 per ``graph_generation``.
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