toolframe-core 0.6.2__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.
Files changed (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
@@ -0,0 +1,267 @@
1
+ """TOML is desired state. Every managed edit is revision checked and atomically replaced."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import secrets
9
+ import tempfile
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Literal
13
+ from urllib.parse import urlsplit
14
+
15
+ import tomlkit
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
17
+ from toolframe_sdk import PluginError
18
+
19
+ from .network import bind_host
20
+
21
+
22
+ def validate_plugin_id(identifier: str) -> str:
23
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", identifier):
24
+ raise ValueError(
25
+ "Plugin IDs must be normalized lowercase slugs (1–64 characters)"
26
+ )
27
+ return identifier
28
+
29
+
30
+ class Model(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+
34
+ class Application(Model):
35
+ id: str
36
+ name: str = "Toolframe"
37
+ host: str = "127.0.0.1"
38
+ port: int = Field(default=9876, ge=0, le=65535)
39
+ public_url: str = ""
40
+
41
+ @field_validator("public_url")
42
+ @classmethod
43
+ def public_origin(cls, value):
44
+ if not value:
45
+ return ""
46
+ uri = urlsplit(value)
47
+ if (
48
+ not uri.hostname
49
+ or uri.username
50
+ or uri.password
51
+ or uri.path not in {"", "/"}
52
+ or uri.query
53
+ or uri.fragment
54
+ or uri.port == 0
55
+ or (
56
+ uri.scheme != "https"
57
+ and not (
58
+ uri.scheme == "http"
59
+ and uri.hostname in {"localhost", "127.0.0.1", "::1"}
60
+ )
61
+ )
62
+ ):
63
+ raise ValueError(
64
+ "public_url must be an HTTPS origin or HTTP loopback origin"
65
+ )
66
+ return value.rstrip("/")
67
+
68
+ @field_validator("host")
69
+ @classmethod
70
+ def listen_address(cls, value):
71
+ return bind_host(value)
72
+
73
+
74
+ class PluginRequest(Model):
75
+ source: str
76
+ enabled: bool = False
77
+ watch: bool = False
78
+ config: dict = Field(default_factory=dict)
79
+
80
+
81
+ class RuntimeSettings(Model):
82
+ drain_timeout: float = Field(default=30, gt=0)
83
+ activation_timeout: float = Field(default=30, gt=0)
84
+ max_task_timeout: float = Field(default=3600, ge=30, le=86400, allow_inf_nan=False)
85
+ disposal_timeout: float = Field(default=10, gt=0)
86
+ health_interval: float = Field(default=10, gt=0)
87
+ health_ttl: float = Field(default=30, gt=0)
88
+ watch_debounce_ms: int = Field(default=500, ge=50)
89
+
90
+ @field_validator("max_task_timeout", mode="before")
91
+ @classmethod
92
+ def task_timeout_policy(cls, value):
93
+ if isinstance(value, bool):
94
+ raise ValueError("max_task_timeout must be a number of seconds") # noqa: TRY004 - Pydantic validators report ValueError as validation failures
95
+ return value
96
+
97
+
98
+ class PackageSettings(Model):
99
+ find_links: list[str] = Field(default_factory=list)
100
+ index_url: str = "https://pypi.org/simple"
101
+ offline: bool = False
102
+
103
+
104
+ class AccessPolicy(Model):
105
+ mode: Literal["public", "protected"] = "public"
106
+ local_control: bool = True
107
+ auth_provider: str = "auth"
108
+ dashboard_auth: str = "dashboard-auth"
109
+ oidc_provider: str = ""
110
+ # Retain the historical interpretation of existing files without this field.
111
+ # New applications write an explicit closed policy during initialization.
112
+ failure_mode: Literal["public", "closed"] = "public"
113
+
114
+ @field_validator("oidc_provider")
115
+ @classmethod
116
+ def oidc_identity(cls, value):
117
+ return validate_plugin_id(value) if value else ""
118
+
119
+ @field_validator("auth_provider", "dashboard_auth")
120
+ @classmethod
121
+ def identities(cls, value):
122
+ return validate_plugin_id(value)
123
+
124
+
125
+ class McpAccessPolicy(Model):
126
+ mode: Literal["public", "protected"] = "public"
127
+ auth_provider: str = "mcp-auth"
128
+
129
+ @field_validator("auth_provider")
130
+ @classmethod
131
+ def identity(cls, value):
132
+ return validate_plugin_id(value)
133
+
134
+
135
+ class Composition(Model):
136
+ schema_version: Literal[1] = 1
137
+ application: Application
138
+ plugins: dict[str, PluginRequest] = Field(default_factory=dict)
139
+ bindings: dict[str, str] = Field(default_factory=dict)
140
+ required_capabilities: list[str] = Field(default_factory=list)
141
+ runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
142
+ packages: PackageSettings = Field(default_factory=PackageSettings)
143
+ access: AccessPolicy = Field(default_factory=AccessPolicy)
144
+ mcp_access: McpAccessPolicy = Field(default_factory=McpAccessPolicy)
145
+
146
+ @field_validator("plugins")
147
+ @classmethod
148
+ def plugin_ids(cls, value):
149
+ for identifier in value:
150
+ validate_plugin_id(identifier)
151
+ return value
152
+
153
+
154
+ def atomic_write(path: Path, content: str, *, mode=0o600):
155
+ path.parent.mkdir(parents=True, exist_ok=True)
156
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
157
+ try:
158
+ with os.fdopen(descriptor, "w") as handle:
159
+ os.fchmod(handle.fileno(), mode)
160
+ handle.write(content)
161
+ handle.flush()
162
+ os.fsync(handle.fileno())
163
+ os.replace(temporary, path)
164
+ directory = os.open(path.parent, os.O_RDONLY)
165
+ try:
166
+ os.fsync(directory)
167
+ finally:
168
+ os.close(directory)
169
+ finally:
170
+ if os.path.exists(temporary):
171
+ os.unlink(temporary)
172
+
173
+
174
+ class ConfigFile:
175
+ def __init__(self, home: Path):
176
+ self.home = home.resolve()
177
+ self.path = self.home / "toolframe.toml"
178
+
179
+ @property
180
+ def revision(self):
181
+ return (
182
+ hashlib.sha256(self.path.read_bytes()).hexdigest()
183
+ if self.path.exists()
184
+ else None
185
+ )
186
+
187
+ def read(self):
188
+ try:
189
+ text = self.path.read_text()
190
+ document = tomlkit.parse(text)
191
+ return Composition.model_validate(document.unwrap()), hashlib.sha256(
192
+ text.encode()
193
+ ).hexdigest()
194
+ except (OSError, ValueError) as error:
195
+ raise PluginError("configuration_invalid", str(error)) from error
196
+
197
+ def initialize(self, *, name="Toolframe", host="127.0.0.1", port=9876):
198
+ if self.path.exists():
199
+ raise PluginError(
200
+ "configuration_exists", "Refusing to overwrite existing toolframe.toml"
201
+ )
202
+ value = Composition(
203
+ application=Application(
204
+ id=str(uuid.uuid4()), name=name, host=host, port=port
205
+ ),
206
+ access=AccessPolicy(failure_mode="closed"),
207
+ )
208
+ atomic_write(self.path, tomlkit.dumps(value.model_dump()))
209
+ return value
210
+
211
+ def edit(self, expected_revision, change):
212
+ if self.revision != expected_revision:
213
+ raise PluginError("stale_plan", "toolframe.toml changed after planning")
214
+ document = tomlkit.parse(self.path.read_text())
215
+ change(document)
216
+ value = Composition.model_validate(document.unwrap())
217
+ # Recheck immediately before rename; host lock serializes managed writers.
218
+ if self.revision != expected_revision:
219
+ raise PluginError(
220
+ "stale_plan", "toolframe.toml changed while applying the edit"
221
+ )
222
+ atomic_write(self.path, tomlkit.dumps(document))
223
+ return value, self.revision
224
+
225
+ def put_plugin(self, identifier, request, expected_revision):
226
+ def change(document):
227
+ if "plugins" not in document:
228
+ document["plugins"] = tomlkit.table()
229
+ document["plugins"][identifier] = request.model_dump()
230
+
231
+ return self.edit(expected_revision, change)
232
+
233
+
234
+ class HostLock:
235
+ def __init__(self, home):
236
+ self.path = Path(home) / ".toolframe" / "host.lock"
237
+ self.file = None
238
+
239
+ def acquire(self):
240
+ import fcntl
241
+
242
+ self.path.parent.mkdir(parents=True, exist_ok=True)
243
+ self.file = self.path.open("a+")
244
+ try:
245
+ fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB)
246
+ except BlockingIOError as error:
247
+ self.file.close()
248
+ self.file = None
249
+ raise PluginError(
250
+ "host_already_running", "Another core owns this application home"
251
+ ) from error
252
+ self.file.seek(0)
253
+ self.file.truncate()
254
+ self.file.write(str(os.getpid()))
255
+ self.file.flush()
256
+
257
+ def close(self):
258
+ if self.file:
259
+ self.file.close()
260
+ self.file = None
261
+
262
+
263
+ def management_token(home):
264
+ path = Path(home) / ".toolframe" / "management.token"
265
+ if not path.exists():
266
+ atomic_write(path, secrets.token_urlsafe(32))
267
+ return path.read_text().strip()
@@ -0,0 +1,200 @@
1
+ """Read-only topology from selected definitions and the lifecycle planner.
2
+
3
+ Never import plugin code or prepare candidates here. Inactive declarations are
4
+ useful for diagnosis, but must not replace the running graph with a newer wheel.
5
+ """
6
+
7
+ from toolframe_sdk import PluginDefinition
8
+
9
+ from .graph import Graph
10
+
11
+ PRESENTATIONS = {
12
+ "dashboard.page": "dashboard",
13
+ "mcp.tool": "mcp",
14
+ "llms.document": "llms",
15
+ }
16
+
17
+
18
+ def dependency_snapshot(graph, plugins, candidates, bindings, cordis, publication):
19
+ definitions = {
20
+ name: PluginDefinition.model_validate(candidate["definition"])
21
+ for name, candidate in candidates.items()
22
+ if name not in graph.definitions
23
+ }
24
+ definitions.update(graph.definitions)
25
+ lifecycle = {item["name"]: item for item in cordis}
26
+ nodes, edges = [], []
27
+ for item in plugins:
28
+ name = item["id"]
29
+ definition = definitions.get(name)
30
+ selected = name in graph.definitions
31
+ observed = lifecycle.get(name, {})
32
+ state = observed.get("state", item["runtime"].get("state", "installed"))
33
+ if not selected:
34
+ if item["desired"].get("installed") is False:
35
+ state = "removed"
36
+ elif item["stopped_override"]:
37
+ state = "stopped"
38
+ elif not item["desired"].get("enabled", False):
39
+ state = "disabled"
40
+ candidate = graph.candidates.get(name, candidates.get(name, {}))
41
+ nodes.append(
42
+ {
43
+ "id": name,
44
+ "label": name,
45
+ "kind": definition.kind if definition else "unknown",
46
+ "provider_kind": definition.provider_kind if definition else None,
47
+ "version": candidate.get("version"),
48
+ "state": state,
49
+ "healthy": item["healthy"],
50
+ "health_fresh": item["health_fresh"],
51
+ "enabled": item["desired"].get("enabled", False),
52
+ "selected": selected,
53
+ "definition_source": "selected"
54
+ if selected
55
+ else "last_candidate"
56
+ if definition
57
+ else "unavailable",
58
+ "in_flight": observed.get("inFlight", 0),
59
+ "blocked_by": observed.get("blockedBy", item["blocked_by"]),
60
+ }
61
+ )
62
+ if not definition:
63
+ continue
64
+ for alias, requirement in sorted(definition.requires.items()):
65
+ resolved = graph.bindings.get(name, {}).get(alias) if selected else None
66
+ # For an unresolved/inactive declaration, show a known supplier only
67
+ # if unambiguous. This is not a new runtime binding.
68
+ explicit = bindings.get(
69
+ f"{name}/{alias}", bindings.get(requirement.service)
70
+ )
71
+ matches = [
72
+ owner
73
+ for owner, target in definitions.items()
74
+ if requirement.service in target.services
75
+ and target.services[requirement.service].version == requirement.version
76
+ and (not explicit or owner == explicit)
77
+ ]
78
+ target = (
79
+ resolved[0] if resolved else matches[0] if len(matches) == 1 else None
80
+ )
81
+ state = "resolved" if resolved else "missing" if selected else "inactive"
82
+ if target is None:
83
+ target = f"requirement:{name}:{alias}"
84
+ nodes.append(
85
+ {
86
+ "id": target,
87
+ "label": f"{requirement.service} v{requirement.version}",
88
+ "kind": "unresolved",
89
+ "state": "ambiguous" if len(matches) > 1 else "missing",
90
+ }
91
+ )
92
+ edges.append(
93
+ {
94
+ "source": name,
95
+ "target": target,
96
+ "kind": "optional" if requirement.optional else "required",
97
+ "label": f"{requirement.service} v{requirement.version}",
98
+ "alias": alias,
99
+ "state": state,
100
+ }
101
+ )
102
+ for resource_kind, provider_kind in PRESENTATIONS.items():
103
+ resources = sorted(
104
+ resource.id
105
+ for resource in definition.resources
106
+ if resource.kind == resource_kind
107
+ )
108
+ if not resources:
109
+ continue
110
+ target = graph.providers.get(provider_kind)
111
+ if target is None:
112
+ matches = [
113
+ owner
114
+ for owner, target_definition in definitions.items()
115
+ if target_definition.provider_kind == provider_kind
116
+ ]
117
+ target = (
118
+ matches[0] if len(matches) == 1 else f"provider:{provider_kind}"
119
+ )
120
+ if target.startswith("provider:") and not any(
121
+ node["id"] == target for node in nodes
122
+ ):
123
+ nodes.append(
124
+ {
125
+ "id": target,
126
+ "label": f"{provider_kind} provider",
127
+ "kind": "unresolved",
128
+ "state": "missing",
129
+ }
130
+ )
131
+ edges.append(
132
+ {
133
+ "source": name,
134
+ "target": target,
135
+ "kind": "presentation",
136
+ "label": resource_kind,
137
+ "resources": resources,
138
+ "state": "inactive"
139
+ if not selected
140
+ else "resolved"
141
+ if target in graph.definitions
142
+ else "missing",
143
+ "publication": publication.get(provider_kind, {}).get(
144
+ "state", "unavailable"
145
+ ),
146
+ }
147
+ )
148
+ impacts = {}
149
+ for name in sorted(graph.definitions):
150
+ impacts[name] = {}
151
+ for action in ("reload", "disable"):
152
+ replacement = (
153
+ graph
154
+ if action == "reload"
155
+ else Graph(
156
+ {
157
+ key: value
158
+ for key, value in graph.candidates.items()
159
+ if key != name
160
+ },
161
+ graph.binding_preferences,
162
+ )
163
+ )
164
+ affected = graph.affected(replacement, {name})
165
+ dependents = graph.dependents({name}) - {name}
166
+ presentations = [
167
+ {
168
+ "owner": edge["source"],
169
+ "provider": edge["target"],
170
+ "kind": edge["label"],
171
+ "resources": edge["resources"],
172
+ }
173
+ for edge in edges
174
+ if edge["kind"] == "presentation"
175
+ and edge["source"] in graph.definitions
176
+ and (edge["source"] in affected or edge["target"] in affected)
177
+ ]
178
+ impacts[name][action] = {
179
+ "affected": sorted(affected),
180
+ "required_dependents": sorted(dependents),
181
+ "presentation_providers": sorted(affected - dependents - {name}),
182
+ "optional_consumers": sorted(
183
+ owner
184
+ for owner, resolved in graph.bindings.items()
185
+ if owner not in affected
186
+ and any(
187
+ target in affected
188
+ and graph.definitions[owner].requires[alias].optional
189
+ for alias, (target, _) in resolved.items()
190
+ )
191
+ ),
192
+ "presentations": presentations,
193
+ }
194
+ return {
195
+ "schema_version": 1,
196
+ "basis": "selected",
197
+ "nodes": sorted(nodes, key=lambda node: node["id"]),
198
+ "edges": edges,
199
+ "impacts": impacts,
200
+ }
@@ -0,0 +1,207 @@
1
+ """Resolve explicit service identities independently of presentation attachment."""
2
+
3
+ from graphlib import CycleError, TopologicalSorter
4
+
5
+ from toolframe_sdk import OwnedResource, PluginDefinition, PluginError, digest
6
+
7
+
8
+ class Graph:
9
+ def __init__(self, candidates, bindings):
10
+ self.candidates = candidates
11
+ self.binding_preferences = dict(bindings)
12
+ self.definitions = {
13
+ name: PluginDefinition.model_validate(value["definition"])
14
+ for name, value in candidates.items()
15
+ }
16
+ self.dependencies = {name: set() for name in candidates}
17
+ self.bindings = {}
18
+ self.missing = {}
19
+ self.providers = {}
20
+ self.storage_providers = set()
21
+ self.stores = {}
22
+ for name, definition in self.definitions.items():
23
+ if definition.provider_kind == "storage":
24
+ self.storage_providers.add(name)
25
+ elif definition.provider_kind:
26
+ if definition.provider_kind in self.providers:
27
+ raise PluginError(
28
+ "provider_conflict",
29
+ f"Only one enabled {definition.provider_kind} provider can own its routes/store",
30
+ )
31
+ self.providers[definition.provider_kind] = name
32
+ for name, definition in self.definitions.items():
33
+ resolved = self.bindings[name] = {}
34
+ missing = self.missing[name] = []
35
+ for alias, requirement in definition.requires.items():
36
+ matches = [
37
+ owner
38
+ for owner, target in self.definitions.items()
39
+ if requirement.service in target.services
40
+ and target.services[requirement.service].version
41
+ == requirement.version
42
+ ]
43
+ explicit = bindings.get(
44
+ f"{name}/{alias}", bindings.get(requirement.service)
45
+ )
46
+ if explicit:
47
+ matches = [owner for owner in matches if owner == explicit]
48
+ if len(matches) > 1:
49
+ raise PluginError(
50
+ "service_ambiguous",
51
+ f"Bind {name}/{alias} explicitly; several plugins provide {requirement.service}",
52
+ )
53
+ if not matches:
54
+ if not requirement.optional:
55
+ missing.append(requirement.service)
56
+ continue
57
+ resolved[alias] = (matches[0], requirement.service)
58
+ if not requirement.optional:
59
+ self.dependencies[name].add(matches[0])
60
+ sql_requirements = {
61
+ alias: requirement
62
+ for alias, requirement in definition.requires.items()
63
+ if requirement.service == "toolframe.storage.sql"
64
+ }
65
+ if definition.store and not any(
66
+ not requirement.optional for requirement in sql_requirements.values()
67
+ ):
68
+ raise PluginError(
69
+ "invalid_store_dependency",
70
+ "A managed store requires a non-optional SQL storage dependency",
71
+ {"owner": name},
72
+ )
73
+ storage = {
74
+ resolved[alias][0] for alias in sql_requirements if alias in resolved
75
+ }
76
+ if len(storage) > 1:
77
+ raise PluginError(
78
+ "multiple_store_providers",
79
+ "Each plugin may bind its SQL store to only one provider",
80
+ {"owner": name, "providers": sorted(storage)},
81
+ )
82
+ if storage:
83
+ provider = next(iter(storage))
84
+ if provider not in self.storage_providers:
85
+ raise PluginError(
86
+ "invalid_storage_provider",
87
+ "The SQL service must be supplied by a storage provider",
88
+ {"owner": name, "provider": provider},
89
+ )
90
+ self.stores[name] = provider
91
+ for resource in definition.resources:
92
+ if resource.kind == "mcp.tool":
93
+ service = resource.data["service"]
94
+ if service in definition.services:
95
+ target = definition.services[service]
96
+ else:
97
+ matching = [
98
+ alias
99
+ for alias, item in definition.requires.items()
100
+ if item.service == service
101
+ ]
102
+ if not matching:
103
+ raise PluginError(
104
+ "resource_target_invalid",
105
+ f"{name}/{resource.id} targets an undeclared service",
106
+ )
107
+ resolution = resolved.get(matching[0])
108
+ target = (
109
+ self.definitions[resolution[0]].services[service]
110
+ if resolution
111
+ else None
112
+ )
113
+ if target and resource.data["method"] not in target.methods:
114
+ raise PluginError(
115
+ "resource_target_invalid",
116
+ f"{name}/{resource.id} targets an unknown method",
117
+ )
118
+ if (
119
+ target
120
+ and target.methods[resource.data["method"]].audience
121
+ != "business"
122
+ ):
123
+ raise PluginError(
124
+ "resource_target_invalid",
125
+ "Public MCP tools must target business methods",
126
+ )
127
+ try:
128
+ self.order = tuple(TopologicalSorter(self.dependencies).static_order())
129
+ except CycleError as error:
130
+ raise PluginError(
131
+ "dependency_cycle", "Required service dependencies contain a cycle"
132
+ ) from error
133
+
134
+ def closure(self, name, *, optional=False, available=None):
135
+ result = set()
136
+
137
+ def visit(owner):
138
+ if owner in result:
139
+ return
140
+ result.add(owner)
141
+ for dependency in self.dependencies.get(owner, ()):
142
+ visit(dependency)
143
+ if optional:
144
+ for dependency, _ in self.bindings.get(owner, {}).values():
145
+ if available is None or dependency in available:
146
+ visit(dependency)
147
+
148
+ visit(name)
149
+ return result
150
+
151
+ def dependents(self, names):
152
+ result = set(names)
153
+ for name in self.order:
154
+ if self.dependencies[name] & result:
155
+ result.add(name)
156
+ return result
157
+
158
+ def resources(self, generations=None):
159
+ generations = generations or {}
160
+ return [
161
+ OwnedResource(
162
+ owner=name,
163
+ generation=generations.get(name, "candidate"),
164
+ resource=resource,
165
+ content=self.candidates[name].get("documents", {}).get(resource.id)
166
+ if resource.kind == "llms.document"
167
+ else None,
168
+ )
169
+ for name in sorted(self.definitions)
170
+ for resource in self.definitions[name].resources
171
+ ]
172
+
173
+ def descriptor_digest(self, kind):
174
+ return digest(
175
+ [
176
+ {
177
+ "owner": resource.owner,
178
+ "resource": resource.resource.model_dump(),
179
+ "content": resource.content,
180
+ }
181
+ for resource in self.resources()
182
+ if resource.resource.kind == kind
183
+ ]
184
+ )
185
+
186
+ def affected(self, replacement, changed):
187
+ result = self.dependents(changed) | replacement.dependents(changed)
188
+ page_owners = {
189
+ resource.owner
190
+ for resource in self.resources() + replacement.resources()
191
+ if resource.resource.kind == "dashboard.page"
192
+ }
193
+ if result & page_owners:
194
+ result |= {
195
+ graph.providers["dashboard"]
196
+ for graph in (self, replacement)
197
+ if "dashboard" in graph.providers
198
+ }
199
+ if self.descriptor_digest("mcp.tool") != replacement.descriptor_digest(
200
+ "mcp.tool"
201
+ ):
202
+ result |= {
203
+ graph.providers["mcp"]
204
+ for graph in (self, replacement)
205
+ if "mcp" in graph.providers
206
+ }
207
+ return result