interloper-toolkit 0.43.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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.3
2
+ Name: interloper-toolkit
3
+ Version: 0.43.0
4
+ Summary: Interloper shared read-only tool functions for AI surfaces (agent, MCP)
5
+ Author: Guillaume Onfroy
6
+ Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: interloper-core
8
+ Requires-Dist: interloper-db
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # interloper-toolkit
13
+
14
+ The read-only tool functions shared by interloper's AI surfaces — the ADK
15
+ chat agent (`interloper-agent`) and the MCP server (`interloper-mcp`).
16
+
17
+ Every function takes a frozen `ToolkitContext(store, catalog, org_id)` as its
18
+ first argument and returns `<SuccessModel> | ToolError` — typed pydantic
19
+ results (see `models.py`) discriminated by the literal `status` field, never
20
+ raising. The models are the tool contract: MCP derives per-tool output
21
+ schemas from them, row-projecting models act as an allowlist of what leaves
22
+ the platform, and full-row payloads embed the interloper-db models to keep
23
+ that coupling visible rather than duplicated. The docstrings are LLM-facing:
24
+ both consumers surface them verbatim as tool descriptions.
25
+
26
+ Modules:
27
+
28
+ - `catalog` — the component definitions the platform ships (list, detail,
29
+ asset schemas, field search, schema comparison)
30
+ - `collection` — the org's component instances (read-only listing; sensitive
31
+ kinds project identity/metadata only)
32
+ - `lineage` — dependency analysis, impact assessment, DAG traversal
33
+ - `scheduling` — jobs, runs, and backfills: read-only monitoring
34
+ - `analytics` — run statistics, partition coverage, data freshness
35
+
36
+ Mutating operations (trigger/toggle/create) deliberately live with the agent,
37
+ not here — this package is safe to expose on read-only surfaces.
38
+
39
+ Depends only on `interloper-core` and `interloper-db`; no LLM-framework
40
+ dependencies (no google-adk, no mcp).
@@ -0,0 +1,29 @@
1
+ # interloper-toolkit
2
+
3
+ The read-only tool functions shared by interloper's AI surfaces — the ADK
4
+ chat agent (`interloper-agent`) and the MCP server (`interloper-mcp`).
5
+
6
+ Every function takes a frozen `ToolkitContext(store, catalog, org_id)` as its
7
+ first argument and returns `<SuccessModel> | ToolError` — typed pydantic
8
+ results (see `models.py`) discriminated by the literal `status` field, never
9
+ raising. The models are the tool contract: MCP derives per-tool output
10
+ schemas from them, row-projecting models act as an allowlist of what leaves
11
+ the platform, and full-row payloads embed the interloper-db models to keep
12
+ that coupling visible rather than duplicated. The docstrings are LLM-facing:
13
+ both consumers surface them verbatim as tool descriptions.
14
+
15
+ Modules:
16
+
17
+ - `catalog` — the component definitions the platform ships (list, detail,
18
+ asset schemas, field search, schema comparison)
19
+ - `collection` — the org's component instances (read-only listing; sensitive
20
+ kinds project identity/metadata only)
21
+ - `lineage` — dependency analysis, impact assessment, DAG traversal
22
+ - `scheduling` — jobs, runs, and backfills: read-only monitoring
23
+ - `analytics` — run statistics, partition coverage, data freshness
24
+
25
+ Mutating operations (trigger/toggle/create) deliberately live with the agent,
26
+ not here — this package is safe to expose on read-only surfaces.
27
+
28
+ Depends only on `interloper-core` and `interloper-db`; no LLM-framework
29
+ dependencies (no google-adk, no mcp).
@@ -0,0 +1,35 @@
1
+ # ###############
2
+ # PROJECT / UV
3
+ # ###############
4
+ [project]
5
+ name = "interloper-toolkit"
6
+ version = "0.43.0"
7
+ description = "Interloper shared read-only tool functions for AI surfaces (agent, MCP)"
8
+ readme = "README.md"
9
+ authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "interloper-core",
13
+ "interloper-db",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.5,<0.12"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.uv.sources]
21
+ interloper-core = { workspace = true }
22
+ interloper-db = { workspace = true }
23
+
24
+ # ###############
25
+ # RUFF
26
+ # ###############
27
+ [tool.ruff]
28
+ line-length = 120
29
+
30
+ [tool.ruff.lint]
31
+ extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202"]
32
+
33
+ [tool.ruff.lint.per-file-ignores]
34
+ "__init__.py" = ["F401", "F403"]
35
+ "tests/**" = ["ANN", "F811"]
@@ -0,0 +1,14 @@
1
+ """Read-only tool functions shared by AI surfaces (agent, MCP server).
2
+
3
+ Every function takes a :class:`~interloper_toolkit.context.ToolkitContext`
4
+ as its first argument and returns ``<SuccessModel> | ToolError`` — typed
5
+ pydantic results (see :mod:`interloper_toolkit.models`) discriminated by
6
+ the literal ``status`` field, never raising. The docstrings are LLM-facing:
7
+ both the ADK agent and the MCP server surface them verbatim as tool
8
+ descriptions.
9
+ """
10
+
11
+ from interloper_toolkit.context import ToolkitContext, serialize
12
+ from interloper_toolkit.models import ToolError
13
+
14
+ __all__ = ["ToolkitContext", "ToolError", "serialize"]
@@ -0,0 +1,153 @@
1
+ """Analytics tools — run statistics, partition coverage, and data freshness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from uuid import UUID
7
+
8
+ from interloper_toolkit.context import ToolkitContext
9
+ from interloper_toolkit.models import (
10
+ FreshnessReport,
11
+ JobFreshness,
12
+ PartitionCoverage,
13
+ RunHistorySummary,
14
+ ToolError,
15
+ )
16
+
17
+
18
+ def run_history_summary(
19
+ ctx: ToolkitContext,
20
+ component_id: str | None = None,
21
+ days: int = 7,
22
+ ) -> RunHistorySummary | ToolError:
23
+ """Summarize run statistics over a period.
24
+
25
+ Args:
26
+ component_id: Filter to a specific job UUID (optional, all jobs if omitted).
27
+ days: Number of days to look back (default 7).
28
+
29
+ Returns aggregate counts (total, success, failed, canceled),
30
+ success rate, and average duration.
31
+ """
32
+ try:
33
+ runs = ctx.store.list_runs(
34
+ ctx.org_id,
35
+ component_id=UUID(component_id) if component_id else None,
36
+ limit=500,
37
+ )
38
+
39
+ cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=days)
40
+ recent = [r for r in runs if r.created_at and r.created_at >= cutoff]
41
+
42
+ total = len(recent)
43
+ by_status: dict[str, int] = {}
44
+ durations: list[float] = []
45
+ for r in recent:
46
+ by_status[r.status] = by_status.get(r.status, 0) + 1
47
+ if r.started_at and r.completed_at:
48
+ durations.append((r.completed_at - r.started_at).total_seconds())
49
+
50
+ success = by_status.get("success", 0)
51
+ return RunHistorySummary(
52
+ period_days=days,
53
+ component_id=component_id,
54
+ total_runs=total,
55
+ by_status=by_status,
56
+ success_rate=round(success / total, 2) if total > 0 else None,
57
+ avg_duration_seconds=round(sum(durations) / len(durations), 1) if durations else None,
58
+ )
59
+ except Exception as e:
60
+ return ToolError(error=str(e))
61
+
62
+
63
+ def partition_coverage(
64
+ ctx: ToolkitContext,
65
+ component_id: str,
66
+ start_date: str,
67
+ end_date: str,
68
+ ) -> PartitionCoverage | ToolError:
69
+ """Check partition coverage for a job over a date range.
70
+
71
+ Args:
72
+ component_id: UUID of the job.
73
+ start_date: Start date in ISO format (YYYY-MM-DD).
74
+ end_date: End date in ISO format (YYYY-MM-DD), inclusive.
75
+
76
+ Returns which dates have successful runs and which are missing.
77
+ """
78
+ try:
79
+ jid = UUID(component_id)
80
+ runs = ctx.store.list_runs(ctx.org_id, component_id=jid, limit=1000)
81
+
82
+ start = datetime.date.fromisoformat(start_date)
83
+ end = datetime.date.fromisoformat(end_date)
84
+
85
+ # Collect dates with successful runs
86
+ covered: set[datetime.date] = set()
87
+ for r in runs:
88
+ if r.status == "success" and r.partition_date:
89
+ if start <= r.partition_date <= end:
90
+ covered.add(r.partition_date)
91
+
92
+ # Build expected date range
93
+ expected: list[datetime.date] = []
94
+ current = start
95
+ while current <= end:
96
+ expected.append(current)
97
+ current += datetime.timedelta(days=1)
98
+
99
+ missing = sorted(set(expected) - covered)
100
+ coverage_pct = round(len(covered) / len(expected) * 100, 1) if expected else 100.0
101
+
102
+ return PartitionCoverage(
103
+ component_id=component_id,
104
+ start_date=start_date,
105
+ end_date=end_date,
106
+ total_days=len(expected),
107
+ covered_days=len(covered),
108
+ missing_days=len(missing),
109
+ coverage_percent=coverage_pct,
110
+ missing_dates=[d.isoformat() for d in missing],
111
+ )
112
+ except Exception as e:
113
+ return ToolError(error=str(e))
114
+
115
+
116
+ def freshness_check(ctx: ToolkitContext) -> FreshnessReport | ToolError:
117
+ """Check data freshness for all jobs.
118
+
119
+ Returns the last successful run timestamp for each job and flags
120
+ any that haven't succeeded in over 24 hours.
121
+ """
122
+ try:
123
+ jobs = ctx.store.list_components(ctx.org_id, kinds=["job"])
124
+ now = datetime.datetime.now(tz=datetime.timezone.utc)
125
+
126
+ results = []
127
+ for job in jobs:
128
+ if not (job.config or {}).get("enabled", True):
129
+ continue
130
+ component_id = job.id
131
+ runs = ctx.store.list_runs(ctx.org_id, component_id=component_id, status="success", limit=1)
132
+ last_success = runs[0] if runs else None
133
+
134
+ hours_since = None
135
+ if last_success and last_success.completed_at:
136
+ delta = now - last_success.completed_at
137
+ hours_since = round(delta.total_seconds() / 3600, 1)
138
+
139
+ results.append(JobFreshness(
140
+ job=job,
141
+ last_success_at=last_success.completed_at if last_success else None,
142
+ hours_since_success=hours_since,
143
+ stale=hours_since is None or hours_since > 24,
144
+ ))
145
+
146
+ stale_count = sum(1 for r in results if r.stale)
147
+ return FreshnessReport(
148
+ total_jobs=len(results),
149
+ stale_count=stale_count,
150
+ jobs=results,
151
+ )
152
+ except Exception as e:
153
+ return ToolError(error=str(e))
@@ -0,0 +1,303 @@
1
+ """Catalog tools — the component definitions the platform ships.
2
+
3
+ Generic over component kinds, mirroring the framework's component
4
+ architecture: one lister and one getter for any kind, plus the
5
+ asset-schema operations that are irreducibly kind-specific.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from interloper.oauth import is_provider_configured
13
+
14
+ from interloper_toolkit.context import ToolkitContext
15
+ from interloper_toolkit.models import (
16
+ AssetSchemaResult,
17
+ DefinitionCounts,
18
+ DefinitionDetail,
19
+ DefinitionEntry,
20
+ DefinitionList,
21
+ FieldMatch,
22
+ FieldSearchResult,
23
+ SchemaComparison,
24
+ SharedField,
25
+ ToolError,
26
+ )
27
+
28
+
29
+ def _inline_refs(node: Any, defs: dict[str, Any] | None = None, _stack: frozenset[str] = frozenset()) -> Any:
30
+ """Resolve local ``#/$defs/*`` references in-place and drop ``$defs``.
31
+
32
+ Gemini rejects tool responses containing JSON-Schema ``$ref`` pointers
33
+ (400 INVALID_ARGUMENT), so every schema leaving the toolkit is inlined:
34
+ a ``$ref`` is replaced by its definition (ref siblings win on conflict);
35
+ cyclic or unresolvable refs are dropped, keeping the siblings.
36
+ """
37
+ if isinstance(node, list):
38
+ return [_inline_refs(item, defs, _stack) for item in node]
39
+ if not isinstance(node, dict):
40
+ return node
41
+ if isinstance(node.get("$defs"), dict):
42
+ defs = {**(defs or {}), **node["$defs"]}
43
+ ref = node.get("$ref")
44
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
45
+ name = ref.rsplit("/", 1)[-1]
46
+ if defs and name in defs and name not in _stack:
47
+ siblings = {k: v for k, v in node.items() if k not in ("$ref", "$defs")}
48
+ return _inline_refs({**defs[name], **siblings}, defs, _stack | {name})
49
+ return {k: _inline_refs(v, defs, _stack) for k, v in node.items() if k not in ("$defs", "$ref")}
50
+
51
+
52
+ def list_definitions(
53
+ ctx: ToolkitContext, kind: str | None = None
54
+ ) -> DefinitionCounts | DefinitionList | ToolError:
55
+ """List the component definitions available in the catalog.
56
+
57
+ This answers "what does Interloper support / what could we add?" — the
58
+ org's collection itself is the Collection specialist's domain.
59
+
60
+ Source entries note how many instances the org's collection holds
61
+ (``in_collection`` / ``collection_count``); connection entries note
62
+ whether OAuth sign-in is supported (``oauth``) and usable in this
63
+ deployment (``oauth_available``) vs manual credential entry.
64
+
65
+ Args:
66
+ kind: Component kind to list — e.g. 'source', 'connection',
67
+ 'destination'. Omit for per-kind counts only; call again with a
68
+ kind for the entries.
69
+ """
70
+ try:
71
+ catalog = ctx.catalog
72
+ counts: dict[str, int] = {}
73
+ for defn in catalog.values():
74
+ counts[defn.get("kind", "?")] = counts.get(defn.get("kind", "?"), 0) + 1
75
+
76
+ if kind is None:
77
+ return DefinitionCounts(
78
+ definition_counts=counts,
79
+ message="Call again with a kind for the entries.",
80
+ )
81
+ if kind not in counts:
82
+ return ToolError(
83
+ error=f"No '{kind}' definitions in the catalog",
84
+ valid_kinds=sorted(counts),
85
+ )
86
+
87
+ collection_counts: dict[str, int] = {}
88
+ if kind == "source":
89
+ for s in ctx.store.list_components(ctx.org_id, kinds=["source"]):
90
+ collection_counts[s.key] = collection_counts.get(s.key, 0) + 1
91
+
92
+ results = []
93
+ for key, defn in catalog.items():
94
+ if defn.get("kind") != kind:
95
+ continue
96
+ entry = DefinitionEntry(
97
+ key=key,
98
+ name=defn.get("name", key),
99
+ description=defn.get("description"),
100
+ icon=defn.get("icon"),
101
+ tags=defn.get("tags", []),
102
+ )
103
+ if kind == "source":
104
+ count = collection_counts.get(key, 0)
105
+ entry.asset_count = len(defn.get("assets", []))
106
+ entry.in_collection = count > 0
107
+ entry.collection_count = count
108
+ elif kind == "connection":
109
+ schema = defn.get("config_schema") or {}
110
+ oauth = schema.get("x-oauth")
111
+ entry.provider = defn.get("provider")
112
+ entry.required_fields = schema.get("required", [])
113
+ entry.oauth = oauth is not None
114
+ entry.oauth_available = is_provider_configured(oauth["provider"]) if oauth else False
115
+ results.append(entry)
116
+
117
+ return DefinitionList(kind=kind, count=len(results), definitions=results)
118
+ except Exception as e:
119
+ return ToolError(error=str(e))
120
+
121
+
122
+ def get_definition(ctx: ToolkitContext, key: str) -> DefinitionDetail | ToolError:
123
+ """Get a component definition's full catalog detail.
124
+
125
+ For a source this includes the config schema, resource slots,
126
+ destination types, and all its assets with their schemas. This is the
127
+ catalog definition (the component *type*), not an instance from the
128
+ org's collection.
129
+
130
+ Args:
131
+ key: The definition's catalog key (e.g. 'facebook_ads',
132
+ 'facebook_ads_connection').
133
+ """
134
+ try:
135
+ defn = ctx.catalog.get(key)
136
+ if defn is None:
137
+ return ToolError(error=f"Definition '{key}' not found in catalog")
138
+ return DefinitionDetail(definition=_inline_refs(defn))
139
+ except Exception as e:
140
+ return ToolError(error=str(e))
141
+
142
+
143
+ # -- Asset schemas (kind-specific by nature) -------------------------------------
144
+
145
+
146
+ def get_asset_schema(ctx: ToolkitContext, source_key: str, asset_key: str) -> AssetSchemaResult | ToolError:
147
+ """Get the JSON schema for a specific asset within a source.
148
+
149
+ Schemas come from the catalog: they are a property of the source
150
+ definition, shared by every instance in the org's collection.
151
+
152
+ Args:
153
+ source_key: The source key (e.g. 'facebook_ads').
154
+ asset_key: The asset key within the source (e.g. 'ad_insights').
155
+
156
+ Returns the asset schema with field names, types, and descriptions.
157
+ """
158
+ try:
159
+ defn = ctx.catalog.get(source_key)
160
+ if defn is None or defn.get("kind") != "source":
161
+ return ToolError(error=f"Source '{source_key}' not found in catalog")
162
+
163
+ for asset_def in defn.get("assets", []):
164
+ if asset_def.get("key") == asset_key or asset_def.get("qualified_key") == f"{source_key}.{asset_key}":
165
+ asset_def = _inline_refs(asset_def)
166
+ return AssetSchemaResult(
167
+ source_key=source_key,
168
+ asset_key=asset_key,
169
+ qualified_key=f"{source_key}.{asset_key}",
170
+ asset_schema=asset_def.get("asset_schema"),
171
+ partitioning=asset_def.get("partitioning"),
172
+ tags=asset_def.get("tags", []),
173
+ requires=asset_def.get("requires", {}),
174
+ optional_requires=asset_def.get("optional_requires", {}),
175
+ )
176
+
177
+ return ToolError(error=f"Asset '{asset_key}' not found in source '{source_key}'")
178
+ except Exception as e:
179
+ return ToolError(error=str(e))
180
+
181
+
182
+ def search_fields(ctx: ToolkitContext, query: str) -> FieldSearchResult | ToolError:
183
+ """Search for fields across all asset schemas in the catalog matching a query string.
184
+
185
+ Searches every source definition's schemas, whether or not the source is
186
+ in the org's collection.
187
+
188
+ Args:
189
+ query: Substring to search for in field names and descriptions (case-insensitive).
190
+
191
+ Returns matching fields grouped by source and asset, with field type and description.
192
+ """
193
+ try:
194
+ query_lower = query.lower()
195
+ matches: list[FieldMatch] = []
196
+
197
+ for key, defn in ctx.catalog.items():
198
+ if defn.get("kind") != "source":
199
+ continue
200
+ source_key = key
201
+ for asset_def in defn.get("assets", []):
202
+ asset_key = asset_def.get("key", "")
203
+ schema = asset_def.get("asset_schema")
204
+ if not schema or "properties" not in schema:
205
+ continue
206
+ for field_name, field_info in schema["properties"].items():
207
+ field_desc = field_info.get("description", "")
208
+ if query_lower in field_name.lower() or query_lower in field_desc.lower():
209
+ matches.append(FieldMatch(
210
+ source_key=source_key,
211
+ asset_key=asset_key,
212
+ qualified_key=f"{source_key}.{asset_key}",
213
+ field_name=field_name,
214
+ field_type=_extract_type(field_info),
215
+ description=field_desc,
216
+ ))
217
+
218
+ return FieldSearchResult(query=query, match_count=len(matches), matches=matches)
219
+ except Exception as e:
220
+ return ToolError(error=str(e))
221
+
222
+
223
+ def compare_schemas(
224
+ ctx: ToolkitContext,
225
+ source_key_a: str,
226
+ asset_key_a: str,
227
+ source_key_b: str,
228
+ asset_key_b: str,
229
+ ) -> SchemaComparison | ToolError:
230
+ """Compare the schemas of two assets side by side.
231
+
232
+ Args:
233
+ source_key_a: Source key for the first asset.
234
+ asset_key_a: Asset key for the first asset.
235
+ source_key_b: Source key for the second asset.
236
+ asset_key_b: Asset key for the second asset.
237
+
238
+ Returns shared fields, fields unique to each asset, and any type mismatches.
239
+ """
240
+ try:
241
+ schema_a = _get_schema_properties(ctx, source_key_a, asset_key_a)
242
+ schema_b = _get_schema_properties(ctx, source_key_b, asset_key_b)
243
+
244
+ if isinstance(schema_a, ToolError):
245
+ return schema_a
246
+ if isinstance(schema_b, ToolError):
247
+ return schema_b
248
+
249
+ fields_a = set(schema_a.keys())
250
+ fields_b = set(schema_b.keys())
251
+
252
+ shared = fields_a & fields_b
253
+ only_a = fields_a - fields_b
254
+ only_b = fields_b - fields_a
255
+
256
+ shared_details = []
257
+ for field in sorted(shared):
258
+ type_a = _extract_type(schema_a[field])
259
+ type_b = _extract_type(schema_b[field])
260
+ shared_details.append(SharedField(
261
+ field=field,
262
+ type_a=type_a,
263
+ type_b=type_b,
264
+ type_match=type_a == type_b,
265
+ ))
266
+
267
+ return SchemaComparison(
268
+ asset_a=f"{source_key_a}.{asset_key_a}",
269
+ asset_b=f"{source_key_b}.{asset_key_b}",
270
+ shared_count=len(shared),
271
+ only_a_count=len(only_a),
272
+ only_b_count=len(only_b),
273
+ shared_fields=shared_details,
274
+ only_in_a=sorted(only_a),
275
+ only_in_b=sorted(only_b),
276
+ )
277
+ except Exception as e:
278
+ return ToolError(error=str(e))
279
+
280
+
281
+ # -- Helpers -------------------------------------------------------------------
282
+
283
+
284
+ def _extract_type(field_info: dict[str, Any]) -> str:
285
+ """Extract a human-readable type string from a JSON schema field definition."""
286
+ if "anyOf" in field_info:
287
+ types = [t.get("type", "unknown") for t in field_info["anyOf"] if t.get("type") != "null"]
288
+ return types[0] if len(types) == 1 else " | ".join(types) if types else "any"
289
+ return field_info.get("type", "unknown")
290
+
291
+
292
+ def _get_schema_properties(ctx: ToolkitContext, source_key: str, asset_key: str) -> dict[str, Any] | ToolError:
293
+ """Retrieve the properties dict from an asset's JSON schema."""
294
+ defn = ctx.catalog.get(source_key)
295
+ if defn is None or defn.get("kind") != "source":
296
+ return ToolError(error=f"Source '{source_key}' not found in catalog")
297
+ for asset_def in defn.get("assets", []):
298
+ if asset_def.get("key") == asset_key:
299
+ schema = asset_def.get("asset_schema")
300
+ if not schema or "properties" not in schema:
301
+ return ToolError(error=f"Asset '{source_key}.{asset_key}' has no schema")
302
+ return _inline_refs(schema)["properties"]
303
+ return ToolError(error=f"Asset '{asset_key}' not found in source '{source_key}'")
@@ -0,0 +1,60 @@
1
+ """Collection tools — the org's component instances, read-only.
2
+
3
+ Creation and connection operations stay with the agent; this module is
4
+ shared with surfaces that must stay read-only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from interloper.component import KINDS
10
+
11
+ from interloper_toolkit.context import ToolkitContext
12
+ from interloper_toolkit.models import ComponentCounts, ComponentList, ComponentSummary, ToolError
13
+
14
+
15
+ def list_components(
16
+ ctx: ToolkitContext, kind: str | None = None
17
+ ) -> ComponentCounts | ComponentList | ToolError:
18
+ """List the components in the organisation's collection.
19
+
20
+ This answers "what do we have?" — what *could* be added (the catalog of
21
+ definitions) is the Catalog specialist's domain. Sensitive kinds
22
+ (connections, configs, resources) always return identity and metadata
23
+ only — never credential or config values.
24
+
25
+ Args:
26
+ kind: Component kind to list — e.g. 'source', 'connection',
27
+ 'destination'. Omit for per-kind counts only; call again with a
28
+ kind for the entries.
29
+ """
30
+ try:
31
+ if kind is None:
32
+ counts: dict[str, int] = {}
33
+ for c in ctx.store.list_components(ctx.org_id):
34
+ counts[c.kind] = counts.get(c.kind, 0) + 1
35
+ return ComponentCounts(
36
+ component_counts=counts,
37
+ message="Call again with a kind for the entries.",
38
+ )
39
+ if kind not in KINDS:
40
+ return ToolError(error=f"Unknown kind '{kind}'", valid_kinds=sorted(KINDS.keys()))
41
+
42
+ results = []
43
+ for c in ctx.store.list_components(ctx.org_id, kinds=[kind]):
44
+ entry = ComponentSummary(
45
+ id=str(c.id),
46
+ key=c.key,
47
+ name=c.name,
48
+ type_name=(ctx.catalog.get(c.key) or {}).get("name", c.key),
49
+ created_at=c.created_at,
50
+ )
51
+ # Fail closed: a sensitive kind's config is (or wraps) credentials.
52
+ if not KINDS[kind].sensitive:
53
+ entry.config = c.config
54
+ if kind == "source":
55
+ entry.asset_count = len(c.children)
56
+ results.append(entry)
57
+
58
+ return ComponentList(kind=kind, count=len(results), components=results)
59
+ except Exception as e:
60
+ return ToolError(error=str(e))