toolgraph 0.0.1__py3-none-any.whl

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/models.py ADDED
@@ -0,0 +1,192 @@
1
+ """Pydantic models: crawl inputs/outputs and authored-governance records.
2
+
3
+ Two families:
4
+ - Crawler config + factual crawl results.
5
+ - Authored governance (policies, ACL grants, data access) — used by manifest
6
+ ingest, defined here so the schema lives in one place.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+
15
+ Transport = Literal["stdio", "streamable-http", "sse"]
16
+
17
+
18
+ def _no_key_delimiter(kind: str, value: str) -> str:
19
+ # "::" is the Tool-key delimiter ("<server>::<tool>"). A component that
20
+ # contains it collapses distinct identities onto one key — e.g.
21
+ # (server="a", tool="b::c") and (server="a::b", tool="c") MERGE the same
22
+ # node — and the ecosystem bundle schema requires exactly one delimiter.
23
+ if "::" in value:
24
+ raise ValueError(f"{kind} must not contain '::' (the tool-key delimiter)")
25
+ return value
26
+
27
+
28
+ class StrictInputModel(BaseModel):
29
+ """Base for operator-authored input: unknown fields are always errors."""
30
+
31
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
32
+
33
+
34
+ # --- crawler config (servers.yaml) ---------------------------------------
35
+
36
+
37
+ class ServerSpec(StrictInputModel):
38
+ """One MCP server to crawl."""
39
+
40
+ name: str | None = None # override; else taken from serverInfo.name
41
+ transport: Transport = "stdio"
42
+
43
+ @field_validator("name")
44
+ @classmethod
45
+ def _name_is_key_safe(cls, value: str | None) -> str | None:
46
+ return value if value is None else _no_key_delimiter("server name", value)
47
+ # stdio
48
+ command: str | None = None
49
+ args: list[str] = Field(default_factory=list)
50
+ env: dict[str, str] = Field(default_factory=dict)
51
+ # http / sse
52
+ url: str | None = None
53
+ headers: dict[str, str] = Field(default_factory=dict)
54
+
55
+
56
+ class ServersConfig(StrictInputModel):
57
+ servers: list[ServerSpec] = Field(default_factory=list)
58
+
59
+
60
+ # --- factual crawl results (EXPOSES / PROVIDES) --------------------------
61
+
62
+
63
+ class ToolRecord(BaseModel):
64
+ name: str
65
+ description: str | None = None
66
+
67
+ @field_validator("name")
68
+ @classmethod
69
+ def _name_is_key_safe(cls, value: str) -> str:
70
+ return _no_key_delimiter("tool name", value)
71
+ input_schema: dict | None = None
72
+ # MCP tool annotations (ADR-0006): the server's self-claims about its own
73
+ # behavior. Crawlable, but unverified by anyone — they surface with
74
+ # ``confidence: medium``, never ``high`` like EXPOSES/PROVIDES.
75
+ read_only_hint: bool | None = None
76
+ destructive_hint: bool | None = None
77
+ idempotent_hint: bool | None = None
78
+ open_world_hint: bool | None = None
79
+
80
+
81
+ class ResourceRecord(BaseModel):
82
+ uri: str
83
+ name: str | None = None
84
+ mime_type: str | None = None
85
+ description: str | None = None
86
+
87
+
88
+ class CrawlResult(BaseModel):
89
+ server_name: str
90
+ server_version: str | None = None
91
+
92
+ @field_validator("server_name")
93
+ @classmethod
94
+ def _server_name_is_key_safe(cls, value: str) -> str:
95
+ return _no_key_delimiter("server name", value)
96
+ transport: Transport = "stdio"
97
+ endpoint: str | None = None # command line or url, for provenance
98
+ tools: list[ToolRecord] = Field(default_factory=list)
99
+ resources: list[ResourceRecord] = Field(default_factory=list)
100
+
101
+
102
+ # --- authored governance (governance.yaml) -------------------------------
103
+
104
+
105
+ # Provenance — per-edge metadata so a reader can tell crawled facts from
106
+ # operator assertions, and find the supporting evidence for each assertion.
107
+ # Default for authored edges is operator_asserted/high with no evidence link;
108
+ # the loader sets crawled/high/<endpoint> on EXPOSES/PROVIDES.
109
+ ProvenanceSource = Literal["crawled", "operator_asserted", "inferred"]
110
+ ProvenanceConfidence = Literal["high", "medium", "low"]
111
+
112
+
113
+ class Provenance(StrictInputModel):
114
+ """Where an edge came from and how strong the claim is."""
115
+
116
+ source: ProvenanceSource = "operator_asserted"
117
+ confidence: ProvenanceConfidence = "high"
118
+ # free-text pointer: file:line, URL, commit SHA, ticket id, runtime trace.
119
+ evidence: str | None = None
120
+
121
+
122
+ class Policy(StrictInputModel):
123
+ # ``extra='forbid'`` catches old manifests that still carry a node-level
124
+ # ``provenance:`` block (the pre-completion-round shape) with a clear
125
+ # Pydantic error pointing at the field, instead of silently ignoring it.
126
+ # The replacement lives on GovernedByBinding (per-edge provenance).
127
+ id: str
128
+ effect: Literal["ALLOW", "DENY"]
129
+ scope: str | None = None
130
+ description: str | None = None
131
+
132
+
133
+ class AccessGrant(StrictInputModel):
134
+ """Agent -[:CAN_CALL]-> Tool. Tool referenced as '<server>::<tool>' or '<tool>'."""
135
+
136
+ agent: str
137
+ tool: str
138
+ granted_by: str | None = None
139
+ provenance: Provenance | None = None
140
+
141
+
142
+ class DataAccess(StrictInputModel):
143
+ """Tool -[:READS|WRITES]-> Resource."""
144
+
145
+ tool: str
146
+ resource: str # resource uri
147
+ mode: Literal["READS", "WRITES"]
148
+ provenance: Provenance | None = None
149
+
150
+
151
+ class ExpectedException(StrictInputModel):
152
+ """Operator-declared exception: this (agent, tool, resource?, policy) reach
153
+ is intentional — surface it as ``authorized_but_governed`` rather than
154
+ ``violation`` so reviewers don't drown in noise on by-design grants."""
155
+
156
+ agent: str
157
+ tool: str # '<server>::<tool>' or bare name (same resolution rules as grants)
158
+ policy: str
159
+ resource: str | None = None # narrow to a specific resource; None = any
160
+ reason: str | None = None
161
+
162
+
163
+ class GovernedByBinding(StrictInputModel):
164
+ """One (node, policy) binding with optional evidence.
165
+
166
+ Lets authors cite WHY this resource (or tool) is bound to this policy —
167
+ e.g. a config file, a runbook, a compliance ticket. Bare strings in
168
+ ``governed_by`` lists are still accepted and lift to this shape with
169
+ ``provenance=None``.
170
+ """
171
+
172
+ policy: str
173
+ provenance: Provenance | None = None
174
+
175
+
176
+ class Governance(StrictInputModel):
177
+ agents: list[str] = Field(default_factory=list)
178
+ policies: list[Policy] = Field(default_factory=list)
179
+ grants: list[AccessGrant] = Field(default_factory=list)
180
+ data_access: list[DataAccess] = Field(default_factory=list)
181
+ # node id -> list of bindings. Each item is either a bare policy_id string
182
+ # (backward-compatible, no provenance) or a GovernedByBinding object with
183
+ # per-binding source citation. node id is a resource uri or '<server>::<tool>'.
184
+ governed_by: dict[str, list[str | GovernedByBinding]] = Field(default_factory=dict)
185
+ expected_exceptions: list[ExpectedException] = Field(default_factory=list)
186
+
187
+
188
+ def edge_provenance_props(p: Provenance | None) -> dict:
189
+ """Default to operator_asserted/high when authoring code omits provenance."""
190
+ if p is None:
191
+ return {"source": "operator_asserted", "confidence": "high", "evidence": None}
192
+ return p.model_dump()
@@ -0,0 +1,212 @@
1
+ """Portable, product-neutral policy bundle compiler.
2
+
3
+ The bundle is a control-plane artifact. It contains deterministic policy
4
+ decisions and crawl contract fingerprints, but no proxy/runtime behavior.
5
+ memtomem-stm is the first reference consumer; independent gateways can consume
6
+ the same schema without importing Toolgraph Python code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+
15
+ from toolgraph.artifacts import (
16
+ canonical_json_bytes,
17
+ rfc3339_offset_error,
18
+ sha256_bytes,
19
+ )
20
+ from toolgraph.graph import queries, selector
21
+ from toolgraph.graph.store import GraphReader, RuntimeGraphStore
22
+ from toolgraph.artifact_safety import safe_artifact, validate_identity
23
+
24
+ SCHEMA_VERSION = 1
25
+ KIND = "toolgraph.policy-bundle"
26
+
27
+
28
+ class PolicyBundleError(RuntimeError):
29
+ """The graph cannot produce a trustworthy policy bundle."""
30
+
31
+
32
+ def tool_contract_digest(contract: dict[str, Any]) -> str:
33
+ fields = {
34
+ key: contract.get(key)
35
+ for key in (
36
+ "server",
37
+ "name",
38
+ "description",
39
+ "input_schema",
40
+ "read_only_hint",
41
+ "destructive_hint",
42
+ "idempotent_hint",
43
+ "open_world_hint",
44
+ )
45
+ }
46
+ return sha256_bytes(canonical_json_bytes(fields))
47
+
48
+
49
+ def _catalog_digest(contracts: list[dict[str, Any]]) -> str:
50
+ rows = [
51
+ {
52
+ "tool_key": row["tool_key"],
53
+ "tool_contract_digest": tool_contract_digest(row),
54
+ }
55
+ for row in contracts
56
+ ]
57
+ return sha256_bytes(canonical_json_bytes(rows))
58
+
59
+
60
+ def build_policy_bundle(
61
+ *,
62
+ agent: str,
63
+ profile: str = selector.DEFAULT_PROFILE,
64
+ created_at: datetime | None = None,
65
+ store: GraphReader | None = None,
66
+ ) -> dict[str, Any]:
67
+ """Compile every currently exposed qualified tool under one policy profile."""
68
+ validate_identity(agent)
69
+ if not agent.strip():
70
+ raise PolicyBundleError("agent must not be empty")
71
+ if profile not in selector.PROFILES:
72
+ raise PolicyBundleError(
73
+ f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
74
+ )
75
+ if created_at is not None:
76
+ problem = rfc3339_offset_error(created_at)
77
+ if problem:
78
+ raise PolicyBundleError(problem)
79
+
80
+ reader = store if store is not None else RuntimeGraphStore()
81
+
82
+ def fetch() -> dict[str, Any]:
83
+ state = reader.state()
84
+ contracts = sorted(reader.exposed_tool_contracts(), key=lambda row: row["tool_key"])
85
+ for contract in contracts:
86
+ validate_identity(contract["tool_key"])
87
+ # Consumers key decisions by tool_key and may reject the whole bundle
88
+ # on a duplicate (the schema's tools description makes this normative),
89
+ # so failing compilation here is strictly better than shipping one.
90
+ duplicates = sorted(
91
+ key
92
+ for key, count in Counter(row["tool_key"] for row in contracts).items()
93
+ if count > 1
94
+ )
95
+ if duplicates:
96
+ raise PolicyBundleError(
97
+ f"duplicate tool_key rows in exposed catalog: {', '.join(duplicates)}"
98
+ " — repair the graph (e.g. multiple EXPOSES edges per tool) before"
99
+ " compiling"
100
+ )
101
+ candidates = [row["tool_key"] for row in contracts]
102
+ # One graph evaluation per bounded chunk when the adapter supports
103
+ # it: the hard filter is a pure function over the ranked features
104
+ # (ADR-0005), so compiling must not run the full selector pass twice
105
+ # per bracket attempt. Adapters without ``evaluate`` keep the original
106
+ # two-call contract — their rank rows never promised selector-internal
107
+ # fields, so we must not filter them ourselves.
108
+ evaluate = getattr(reader, "evaluate", None)
109
+ filtered = {"eligible": [], "rejected": []}
110
+ features = []
111
+ # The empty catalog still evaluates identity once. Every chunk is
112
+ # inside the same bracket; a state change retries the entire catalog.
113
+ chunks = [
114
+ candidates[i:i + selector.MAX_CANDIDATES]
115
+ for i in range(0, len(candidates), selector.MAX_CANDIDATES)
116
+ ] or [[]]
117
+ for chunk in chunks:
118
+ if evaluate is not None:
119
+ evaluated = evaluate(agent, chunk, profile)
120
+ if not evaluated["agent_found"]:
121
+ raise PolicyBundleError(f"agent {agent!r} not found in the graph")
122
+ decisions = evaluated["filtered"]
123
+ rows = evaluated["features"]
124
+ else:
125
+ decisions = reader.eligible_tools(agent, chunk, profile)
126
+ ranked = reader.rank_features(agent, chunk)
127
+ if not decisions["agent_found"] or not ranked["agent_found"]:
128
+ raise PolicyBundleError(f"agent {agent!r} not found in the graph")
129
+ rows = ranked["features"]
130
+ expected = Counter(chunk)
131
+ feature_keys = Counter(row.get("tool_key") or row["candidate"] for row in rows)
132
+ if feature_keys != expected:
133
+ raise PolicyBundleError(
134
+ "selector returned no feature row or duplicate/unexpected "
135
+ "feature rows for catalog"
136
+ )
137
+ decision_keys = Counter(decisions["eligible"])
138
+ decision_keys.update(
139
+ row.get("tool_key") or row["candidate"]
140
+ for row in decisions["rejected"]
141
+ )
142
+ if decision_keys != expected:
143
+ raise PolicyBundleError("selector returned missing, duplicate, or conflicting decisions")
144
+ filtered["eligible"].extend(decisions["eligible"])
145
+ filtered["rejected"].extend(decisions["rejected"])
146
+ features.extend(rows)
147
+ return {
148
+ "governance_digest": state.governance_digest,
149
+ "contracts": contracts,
150
+ "filtered": filtered,
151
+ "features": features,
152
+ }
153
+
154
+ compiled = queries.with_graph_state(fetch, strict=True, state_reader=reader.state)
155
+ graph_state = compiled["graph_state"]
156
+ if not isinstance(graph_state.get("instance_id"), str) or not graph_state["instance_id"]:
157
+ raise PolicyBundleError(
158
+ "graph has no instance id — run init-schema before compiling"
159
+ )
160
+ governance_digest = compiled["governance_digest"]
161
+ if not governance_digest:
162
+ raise PolicyBundleError(
163
+ "graph has no governance digest — re-run ingest-manifest before compiling"
164
+ )
165
+
166
+ rejected = {
167
+ row.get("tool_key") or row["candidate"]: row
168
+ for row in compiled["filtered"]["rejected"]
169
+ }
170
+ features = {
171
+ row.get("tool_key") or row["candidate"]: row
172
+ for row in compiled["features"]
173
+ }
174
+ tools: list[dict[str, Any]] = []
175
+ for contract in compiled["contracts"]:
176
+ key = contract["tool_key"]
177
+ rejection = rejected.get(key)
178
+ feature = features.get(key)
179
+ if feature is None:
180
+ # Both passes ran inside one graph-state bracket, so every catalog
181
+ # key must have a feature row; a miss means the selector contract
182
+ # broke — fail as a typed compile error, not a KeyError traceback.
183
+ raise PolicyBundleError(
184
+ f"selector returned no feature row for catalog tool {key!r}"
185
+ )
186
+ item: dict[str, Any] = {
187
+ "tool_key": key,
188
+ "tool_contract_digest": tool_contract_digest(contract),
189
+ "decision": "rejected" if rejection else "eligible",
190
+ "risk_score": feature["risk_score"],
191
+ }
192
+ if rejection:
193
+ item["reason"] = rejection["reason"]
194
+ if rejection.get("paths"):
195
+ item["paths"] = rejection["paths"]
196
+ tools.append(item)
197
+
198
+ timestamp = created_at or datetime.now(timezone.utc)
199
+ return safe_artifact(
200
+ {
201
+ "schema_version": SCHEMA_VERSION,
202
+ "kind": KIND,
203
+ "created_at": timestamp.isoformat(),
204
+ "graph_state": compiled["graph_state"],
205
+ "agent": agent,
206
+ "agent_found": True,
207
+ "profile": profile,
208
+ "governance_digest": governance_digest,
209
+ "catalog_digest": _catalog_digest(compiled["contracts"]),
210
+ "tools": tools,
211
+ }
212
+ )
@@ -0,0 +1,97 @@
1
+ """Non-mutating bridge from accepted Tracegraph evidence to policy work.
2
+
3
+ Trace failures are observations, not authorization facts. This compiler makes
4
+ the hand-off explicit: it binds accepted human review dispositions to the
5
+ current graph decision and produces a private work-plan artifact. Applying a
6
+ change still requires editing and ingesting an authored governance manifest.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from toolgraph.artifacts import rfc3339_offset_error
16
+ from toolgraph.artifact_safety import safe_artifact, validate_identity
17
+ from toolgraph.graph import queries, selector
18
+ from toolgraph.review_candidates import ReviewCandidateError, list_candidates
19
+
20
+ SCHEMA_VERSION = 1
21
+ KIND = "toolgraph.policy-review-plan"
22
+
23
+
24
+ class PolicyReviewPlanError(RuntimeError):
25
+ """Accepted evidence cannot be projected onto a trustworthy graph state."""
26
+
27
+
28
+ def build_policy_review_plan(
29
+ *,
30
+ report: Path,
31
+ agent: str,
32
+ profile: str = selector.DEFAULT_PROFILE,
33
+ annotations: Path | None = None,
34
+ created_at: datetime | None = None,
35
+ ) -> dict[str, Any]:
36
+ """Bind accepted Tracegraph candidates to current Toolgraph decisions."""
37
+ validate_identity(agent)
38
+ if created_at is not None and (problem := rfc3339_offset_error(created_at)):
39
+ raise PolicyReviewPlanError(problem)
40
+ if not agent.strip():
41
+ raise PolicyReviewPlanError("agent must not be empty")
42
+ if profile not in selector.PROFILES:
43
+ raise PolicyReviewPlanError(f"unknown profile {profile!r}")
44
+ try:
45
+ reviewed = list_candidates(
46
+ report, annotations_path=annotations, status="accepted"
47
+ )
48
+ except ReviewCandidateError as exc:
49
+ raise PolicyReviewPlanError(str(exc)) from exc
50
+ accepted = reviewed["candidates"]
51
+ if not accepted:
52
+ raise PolicyReviewPlanError("the review report has no accepted candidates")
53
+
54
+ tool_keys = sorted({row["tool_key"] for row in accepted})
55
+
56
+ def fetch() -> dict[str, Any]:
57
+ verdict = selector.eligible_tools(agent, tool_keys, profile)
58
+ if not verdict["agent_found"]:
59
+ raise PolicyReviewPlanError(f"agent {agent!r} not found in the graph")
60
+ return verdict
61
+
62
+ compiled = queries.with_graph_state(fetch, strict=True)
63
+ rejected = {
64
+ row.get("tool_key") or row["candidate"]: row
65
+ for row in compiled["rejected"]
66
+ }
67
+ candidates = []
68
+ for row in accepted:
69
+ rejection = rejected.get(row["tool_key"])
70
+ candidates.append(
71
+ {
72
+ "candidate_id": row["candidate_id"],
73
+ "run_id": row["run_id"],
74
+ "pattern_id": row["pattern_id"],
75
+ "pattern_version": row["pattern_version"],
76
+ "tool_key": row["tool_key"],
77
+ "artifact_digest": row["artifact_digest"],
78
+ "current_decision": "rejected" if rejection else "eligible",
79
+ "current_reason": rejection.get("reason") if rejection else None,
80
+ "current_paths": rejection.get("paths", []) if rejection else [],
81
+ "recommended_action": "review_governance_manifest",
82
+ "automatic_change": False,
83
+ }
84
+ )
85
+
86
+ timestamp = created_at or datetime.now(timezone.utc)
87
+ return safe_artifact({
88
+ "schema_version": SCHEMA_VERSION,
89
+ "kind": KIND,
90
+ "created_at": timestamp.isoformat(),
91
+ "source_report_digest": reviewed["source_report_digest"],
92
+ "graph_state": compiled["graph_state"],
93
+ "agent": agent,
94
+ "profile": profile,
95
+ "decision_mode": "human_required",
96
+ "candidates": candidates,
97
+ })
toolgraph/preflight.py ADDED
@@ -0,0 +1,77 @@
1
+ """Deterministic advisory preflight artifact production."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from toolgraph.artifacts import rfc3339_offset_error
7
+ from toolgraph.artifact_safety import safe_artifact, validate_identity
8
+ from toolgraph.artifact_safety import redact as redact
9
+
10
+ from toolgraph.graph import queries, selector
11
+
12
+ SCHEMA_VERSION = 1
13
+ KIND = "toolgraph.preflight"
14
+
15
+ def build_preflight(
16
+ *,
17
+ agent: str,
18
+ candidates: list[str],
19
+ profile: str,
20
+ run_id: str,
21
+ include_features: bool = False,
22
+ created_at: datetime | None = None,
23
+ ) -> dict:
24
+ """Build one schema-v1 advisory verdict over a bracketed graph state."""
25
+ for identity in [agent, run_id, *candidates]:
26
+ validate_identity(identity)
27
+ if created_at is not None and (problem := rfc3339_offset_error(created_at)):
28
+ raise ValueError(problem)
29
+ if not run_id:
30
+ raise ValueError("run_id must not be empty")
31
+ if profile not in selector.PROFILES:
32
+ raise ValueError(
33
+ f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
34
+ )
35
+
36
+ def fetch() -> dict:
37
+ # One graph evaluation: the filter is a pure function over the ranked
38
+ # features, so --features must not pay a second full pass.
39
+ ranked = selector.rank_features(agent, candidates)
40
+ filtered = selector.filter_features(ranked, profile)
41
+ result = {
42
+ "agent": agent,
43
+ "agent_found": filtered["agent_found"],
44
+ "profile": profile,
45
+ "eligible": filtered["eligible"],
46
+ "rejected": filtered["rejected"],
47
+ }
48
+ if include_features:
49
+ result["features"] = ranked["features"]
50
+ return result
51
+
52
+ verdict = queries.with_graph_state(fetch, strict=True)
53
+ if not verdict["agent_found"]:
54
+ decision = "unresolved_identity"
55
+ elif verdict["rejected"]:
56
+ decision = "advisory_warn"
57
+ else:
58
+ decision = "advisory_allow"
59
+ timestamp = created_at or datetime.now(timezone.utc)
60
+ artifact = {
61
+ "schema_version": SCHEMA_VERSION,
62
+ "kind": KIND,
63
+ "run_id": run_id,
64
+ "created_at": timestamp.isoformat(),
65
+ "graph_generation": verdict["graph_generation"],
66
+ "graph_instance_id": verdict["graph_instance_id"],
67
+ "graph_state": verdict["graph_state"],
68
+ "agent": agent,
69
+ "agent_found": verdict["agent_found"],
70
+ "profile": profile,
71
+ "mode": "advisory", # v1 emits advisory artifacts only
72
+ "eligible": verdict["eligible"],
73
+ "rejected": verdict["rejected"],
74
+ "features": verdict.get("features", []),
75
+ }
76
+ artifact["decision"] = decision
77
+ return safe_artifact(artifact)
@@ -0,0 +1,28 @@
1
+ agents:
2
+ - vibe-coder
3
+
4
+ policies:
5
+ - id: draft-publish-deny
6
+ effect: DENY
7
+ scope: demo-drafts
8
+ description: Publishing demo drafts requires an explicit policy review.
9
+
10
+ grants:
11
+ - agent: vibe-coder
12
+ tool: "policy-gateway::read_note"
13
+ granted_by: quickstart
14
+ - agent: vibe-coder
15
+ tool: "policy-gateway::publish_note"
16
+ granted_by: quickstart
17
+
18
+ data_access:
19
+ - tool: "policy-gateway::read_note"
20
+ resource: "file:///demo/notes"
21
+ mode: READS
22
+ - tool: "policy-gateway::publish_note"
23
+ resource: "file:///demo/drafts"
24
+ mode: WRITES
25
+
26
+ governed_by:
27
+ "file:///demo/drafts":
28
+ - draft-publish-deny
@@ -0,0 +1,31 @@
1
+ """Deterministic, local-only MCP server for the Toolgraph quickstart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+ from mcp.types import ToolAnnotations
7
+
8
+
9
+ def build() -> FastMCP:
10
+ server = FastMCP("policy-gateway")
11
+
12
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True))
13
+ def read_note(note_id: str) -> str:
14
+ """Read a deterministic demo note."""
15
+ return f"note:{note_id}"
16
+
17
+ @server.tool(annotations=ToolAnnotations(destructiveHint=True))
18
+ def publish_note(note_id: str, content: str) -> str:
19
+ """Publish a demo note to the governed draft area."""
20
+ return f"published:{note_id}:{len(content)}"
21
+
22
+ @server.resource("file:///demo/notes")
23
+ def notes() -> str:
24
+ """Describe the deterministic demo note collection."""
25
+ return "policy-gateway quickstart notes"
26
+
27
+ return server
28
+
29
+
30
+ if __name__ == "__main__":
31
+ build().run()
@@ -0,0 +1,5 @@
1
+ servers:
2
+ - name: policy-gateway
3
+ transport: stdio
4
+ command: __TOOLGRAPH_PYTHON__
5
+ args: ["policy_gateway_server.py"]