interloper-mcp 0.42.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,95 @@
1
+ Metadata-Version: 2.3
2
+ Name: interloper-mcp
3
+ Version: 0.42.0
4
+ Summary: Interloper MCP server — read-only platform access for AI agents
5
+ Author: Guillaume Onfroy
6
+ Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: interloper-core
8
+ Requires-Dist: interloper-db
9
+ Requires-Dist: interloper-toolkit
10
+ Requires-Dist: mcp>=1.28,<2
11
+ Requires-Dist: uvicorn[standard]>=0.30.0
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+
15
+ # interloper-mcp
16
+
17
+ A [Model Context Protocol](https://modelcontextprotocol.io) server exposing
18
+ read-only interloper platform access to AI agents: catalog definitions,
19
+ collection listings, lineage, run/backfill monitoring, and analytics.
20
+
21
+ Authentication uses **personal access tokens** (PATs) minted via the
22
+ interloper API (`POST /api/tokens`, session-authenticated). Tokens are
23
+ org-scoped and carry the holder's live role; revocation and membership
24
+ removal apply immediately.
25
+
26
+ ## Running
27
+
28
+ Streamable HTTP (default; binds `INTERLOPER_MCP_HOST:INTERLOPER_MCP_PORT`,
29
+ endpoint `/mcp`):
30
+
31
+ ```sh
32
+ interloper-mcp
33
+ ```
34
+
35
+ Clients authenticate every request with `Authorization: Bearer ilp_...`.
36
+
37
+ stdio (single-user, authenticates once at startup):
38
+
39
+ ```sh
40
+ INTERLOPER_MCP_TOKEN=ilp_... interloper-mcp --transport stdio
41
+ ```
42
+
43
+ For local development without a token, `INTERLOPER_MCP_ORG_ID=<uuid>` scopes
44
+ the stdio server to one organisation directly.
45
+
46
+ ## Connecting clients
47
+
48
+ MCP Inspector:
49
+
50
+ ```sh
51
+ npx @modelcontextprotocol/inspector
52
+ # Transport: Streamable HTTP, URL: http://localhost:3001/mcp
53
+ # Header: Authorization: Bearer ilp_...
54
+ ```
55
+
56
+ Claude Code:
57
+
58
+ ```sh
59
+ claude mcp add --transport http interloper http://localhost:3001/mcp \
60
+ --header "Authorization: Bearer ilp_..."
61
+ ```
62
+
63
+ ## Settings
64
+
65
+ | Env var | Default | Purpose |
66
+ | --- | --- | --- |
67
+ | `INTERLOPER_MCP_HOST` | `0.0.0.0` | HTTP bind host |
68
+ | `INTERLOPER_MCP_PORT` | `3001` | HTTP bind port |
69
+ | `INTERLOPER_MCP_EXTERNAL_URL` | — | Public base URL (e.g. `https://mcp.interloper.app`), used in OAuth protected-resource metadata |
70
+ | `INTERLOPER_MCP_TOKEN` | — | stdio only: PAT to authenticate as |
71
+ | `INTERLOPER_MCP_ORG_ID` | — | stdio only: dev fallback, direct org scope |
72
+
73
+ Database and catalog configuration is the standard interloper set
74
+ (`INTERLOPER_POSTGRES_*`, `INTERLOPER_CATALOG`, ...).
75
+
76
+ ## Deployment (not yet wired)
77
+
78
+ The container target would follow the existing dockerfile pattern:
79
+
80
+ ```dockerfile
81
+ FROM base AS build-mcp
82
+ COPY packages ./packages
83
+ RUN docker/uv-sync.sh interloper-core interloper-assets interloper-db interloper-toolkit interloper-mcp
84
+
85
+ FROM runtime AS mcp
86
+ COPY --from=build-mcp /interloper/.venv /interloper/.venv
87
+ USER app
88
+ EXPOSE 3001
89
+ CMD ["interloper-mcp"]
90
+ ```
91
+
92
+ plus a `mcp` role in the Makefile/publish workflow, a Helm/Flux workload
93
+ (`apps/interloper/mcp/` with an HTTPRoute for `mcp.interloper.app` — wildcard
94
+ DNS + TLS already cover it), and its own GSA/IAM DB user in terraform if it
95
+ should not share the app's identity.
@@ -0,0 +1,81 @@
1
+ # interloper-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server exposing
4
+ read-only interloper platform access to AI agents: catalog definitions,
5
+ collection listings, lineage, run/backfill monitoring, and analytics.
6
+
7
+ Authentication uses **personal access tokens** (PATs) minted via the
8
+ interloper API (`POST /api/tokens`, session-authenticated). Tokens are
9
+ org-scoped and carry the holder's live role; revocation and membership
10
+ removal apply immediately.
11
+
12
+ ## Running
13
+
14
+ Streamable HTTP (default; binds `INTERLOPER_MCP_HOST:INTERLOPER_MCP_PORT`,
15
+ endpoint `/mcp`):
16
+
17
+ ```sh
18
+ interloper-mcp
19
+ ```
20
+
21
+ Clients authenticate every request with `Authorization: Bearer ilp_...`.
22
+
23
+ stdio (single-user, authenticates once at startup):
24
+
25
+ ```sh
26
+ INTERLOPER_MCP_TOKEN=ilp_... interloper-mcp --transport stdio
27
+ ```
28
+
29
+ For local development without a token, `INTERLOPER_MCP_ORG_ID=<uuid>` scopes
30
+ the stdio server to one organisation directly.
31
+
32
+ ## Connecting clients
33
+
34
+ MCP Inspector:
35
+
36
+ ```sh
37
+ npx @modelcontextprotocol/inspector
38
+ # Transport: Streamable HTTP, URL: http://localhost:3001/mcp
39
+ # Header: Authorization: Bearer ilp_...
40
+ ```
41
+
42
+ Claude Code:
43
+
44
+ ```sh
45
+ claude mcp add --transport http interloper http://localhost:3001/mcp \
46
+ --header "Authorization: Bearer ilp_..."
47
+ ```
48
+
49
+ ## Settings
50
+
51
+ | Env var | Default | Purpose |
52
+ | --- | --- | --- |
53
+ | `INTERLOPER_MCP_HOST` | `0.0.0.0` | HTTP bind host |
54
+ | `INTERLOPER_MCP_PORT` | `3001` | HTTP bind port |
55
+ | `INTERLOPER_MCP_EXTERNAL_URL` | — | Public base URL (e.g. `https://mcp.interloper.app`), used in OAuth protected-resource metadata |
56
+ | `INTERLOPER_MCP_TOKEN` | — | stdio only: PAT to authenticate as |
57
+ | `INTERLOPER_MCP_ORG_ID` | — | stdio only: dev fallback, direct org scope |
58
+
59
+ Database and catalog configuration is the standard interloper set
60
+ (`INTERLOPER_POSTGRES_*`, `INTERLOPER_CATALOG`, ...).
61
+
62
+ ## Deployment (not yet wired)
63
+
64
+ The container target would follow the existing dockerfile pattern:
65
+
66
+ ```dockerfile
67
+ FROM base AS build-mcp
68
+ COPY packages ./packages
69
+ RUN docker/uv-sync.sh interloper-core interloper-assets interloper-db interloper-toolkit interloper-mcp
70
+
71
+ FROM runtime AS mcp
72
+ COPY --from=build-mcp /interloper/.venv /interloper/.venv
73
+ USER app
74
+ EXPOSE 3001
75
+ CMD ["interloper-mcp"]
76
+ ```
77
+
78
+ plus a `mcp` role in the Makefile/publish workflow, a Helm/Flux workload
79
+ (`apps/interloper/mcp/` with an HTTPRoute for `mcp.interloper.app` — wildcard
80
+ DNS + TLS already cover it), and its own GSA/IAM DB user in terraform if it
81
+ should not share the app's identity.
@@ -0,0 +1,42 @@
1
+ # ###############
2
+ # PROJECT / UV
3
+ # ###############
4
+ [project]
5
+ name = "interloper-mcp"
6
+ version = "0.42.0"
7
+ description = "Interloper MCP server — read-only platform access for AI agents"
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
+ "interloper-toolkit",
15
+ "mcp>=1.28,<2",
16
+ "uvicorn[standard]>=0.30.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ interloper-mcp = "interloper_mcp.main:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.11.5,<0.12"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv.sources]
27
+ interloper-core = { workspace = true }
28
+ interloper-db = { workspace = true }
29
+ interloper-toolkit = { workspace = true }
30
+
31
+ # ###############
32
+ # RUFF
33
+ # ###############
34
+ [tool.ruff]
35
+ line-length = 120
36
+
37
+ [tool.ruff.lint]
38
+ extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202"]
39
+
40
+ [tool.ruff.lint.per-file-ignores]
41
+ "__init__.py" = ["F401", "F403"]
42
+ "tests/**" = ["ANN", "F811"]
@@ -0,0 +1 @@
1
+ """Interloper MCP server — read-only platform access for AI agents."""
@@ -0,0 +1,55 @@
1
+ """Bearer-token verification: personal access tokens as MCP access tokens.
2
+
3
+ The SDK treats the server as an OAuth resource server; a
4
+ :class:`~mcp.server.auth.provider.TokenVerifier` is its seam for validating
5
+ whatever bearer token arrives. Interloper PATs slot straight in — and an
6
+ OAuth authorization server can later mint short-lived tokens verified through
7
+ this same seam without touching the tools.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import anyio.to_thread
13
+ from interloper_db import Store
14
+ from mcp.server.auth.provider import AccessToken, TokenVerifier
15
+
16
+
17
+ class PatAccessToken(AccessToken):
18
+ """An access token backed by a verified personal access token."""
19
+
20
+ org_id: str
21
+ role: str
22
+ profile_id: str
23
+
24
+
25
+ class PatVerifier(TokenVerifier):
26
+ """Verify raw bearer tokens against the PAT store.
27
+
28
+ Resolution enforces revocation, expiry, and live org membership; the
29
+ resulting token carries the tenant scope every tool call runs under.
30
+ """
31
+
32
+ def __init__(self, store: Store) -> None:
33
+ self._store = store
34
+
35
+ async def verify_token(self, token: str) -> AccessToken | None:
36
+ """Resolve a raw bearer token; ``None`` rejects the request with a 401.
37
+
38
+ Args:
39
+ token: The bearer token exactly as presented by the client.
40
+ """
41
+ # The store is synchronous — keep the event loop free.
42
+ result = await anyio.to_thread.run_sync(self._store.resolve_token, token)
43
+ if result is None:
44
+ return None
45
+
46
+ profile, pat, role = result
47
+ return PatAccessToken(
48
+ token=token,
49
+ client_id=str(profile.id),
50
+ scopes=[f"role:{role}"],
51
+ expires_at=int(pat.expires_at.timestamp()) if pat.expires_at else None,
52
+ org_id=str(pat.organisation_id),
53
+ role=role,
54
+ profile_id=str(profile.id),
55
+ )
@@ -0,0 +1,82 @@
1
+ """Process globals and per-request toolkit context resolution.
2
+
3
+ Two authentication paths converge on :func:`get_ctx`:
4
+
5
+ - **Streamable HTTP**: every request carries a bearer PAT; the SDK's auth
6
+ middleware stores the verified token in a contextvar, read back here via
7
+ ``get_access_token()``.
8
+ - **stdio**: single-user by nature; the context is resolved once at startup
9
+ (from a PAT or a direct org scope) and seeded via :func:`set_static_ctx`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from contextvars import ContextVar
15
+ from typing import Any
16
+ from uuid import UUID
17
+
18
+ from interloper.catalog.base import Catalog
19
+ from interloper_db import Store
20
+ from interloper_toolkit import ToolkitContext
21
+ from mcp.server.auth.middleware.auth_context import get_access_token
22
+
23
+ from interloper_mcp.auth import PatAccessToken
24
+
25
+ _store: Store | None = None
26
+ _catalog_dump: dict[str, Any] | None = None
27
+
28
+ _static_ctx: ContextVar[ToolkitContext | None] = ContextVar("interloper_mcp_static_ctx", default=None)
29
+
30
+
31
+ def init_context(store: Store, catalog: Catalog) -> None:
32
+ """Set the process-wide store and catalog (once, at startup).
33
+
34
+ Args:
35
+ store: The Store every request queries.
36
+ catalog: The catalog; dumped once — definitions don't change at runtime.
37
+ """
38
+ global _store, _catalog_dump # noqa: PLW0603
39
+ _store = store
40
+ _catalog_dump = catalog.dump()
41
+
42
+
43
+ def set_static_ctx(org_id: UUID) -> None:
44
+ """Seed the static (stdio) context with an organisation scope.
45
+
46
+ Args:
47
+ org_id: The organisation all tool calls are scoped to.
48
+ """
49
+ _static_ctx.set(ToolkitContext(store=_require_store(), catalog=_require_catalog(), org_id=org_id))
50
+
51
+
52
+ def get_ctx() -> ToolkitContext:
53
+ """Build the toolkit context for the current tool call.
54
+
55
+ Returns:
56
+ The org-scoped context — from the request's verified bearer token
57
+ (HTTP) or the startup-seeded scope (stdio).
58
+
59
+ Raises:
60
+ RuntimeError: When no authenticated context exists (a programming
61
+ error: the transport must enforce auth before tools run).
62
+ """
63
+ token = get_access_token()
64
+ if isinstance(token, PatAccessToken):
65
+ return ToolkitContext(store=_require_store(), catalog=_require_catalog(), org_id=UUID(token.org_id))
66
+
67
+ ctx = _static_ctx.get()
68
+ if ctx is None:
69
+ raise RuntimeError("No authenticated context for this tool call")
70
+ return ctx
71
+
72
+
73
+ def _require_store() -> Store:
74
+ if _store is None:
75
+ raise RuntimeError("MCP context not initialized. Call init_context() first.")
76
+ return _store
77
+
78
+
79
+ def _require_catalog() -> dict[str, Any]:
80
+ if _catalog_dump is None:
81
+ raise RuntimeError("MCP context not initialized. Call init_context() first.")
82
+ return _catalog_dump
@@ -0,0 +1,84 @@
1
+ """Console entry point for the interloper MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from uuid import UUID
9
+
10
+ from interloper.catalog.base import Catalog
11
+ from interloper.settings import AppSettings
12
+ from interloper_db import Store
13
+
14
+ from interloper_mcp.context import init_context, set_static_ctx
15
+ from interloper_mcp.server import create_mcp_server
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def main() -> None:
21
+ """Run the MCP server on the selected transport."""
22
+ parser = argparse.ArgumentParser(prog="interloper-mcp", description="Interloper MCP server (read-only)")
23
+ parser.add_argument(
24
+ "--transport",
25
+ choices=["streamable-http", "stdio"],
26
+ default="streamable-http",
27
+ help="streamable-http serves bearer-authenticated HTTP (default); stdio authenticates once at startup",
28
+ )
29
+ parser.add_argument("--host", default=None, help="Bind host (overrides INTERLOPER_MCP_HOST)")
30
+ parser.add_argument("--port", type=int, default=None, help="Bind port (overrides INTERLOPER_MCP_PORT)")
31
+ args = parser.parse_args()
32
+
33
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr)
34
+
35
+ settings = AppSettings.get().mcp
36
+ if args.host is not None:
37
+ settings.host = args.host
38
+ if args.port is not None:
39
+ settings.port = args.port
40
+
41
+ catalog = Catalog.from_settings()
42
+ store = Store.from_settings(catalog=catalog)
43
+ init_context(store, catalog)
44
+
45
+ if args.transport == "stdio":
46
+ set_static_ctx(_resolve_stdio_org(store, settings.token, settings.org_id))
47
+ mcp = create_mcp_server(settings, store=None)
48
+ mcp.run(transport="stdio")
49
+ else:
50
+ mcp = create_mcp_server(settings, store=store)
51
+ logger.info("Serving streamable HTTP on %s:%s/mcp", settings.host, settings.port)
52
+ mcp.run(transport="streamable-http")
53
+
54
+
55
+ def _resolve_stdio_org(store: Store, token: str, org_id: str) -> UUID:
56
+ """Resolve the organisation scope for the stdio transport.
57
+
58
+ A PAT (``INTERLOPER_MCP_TOKEN``) is the normal path; a bare
59
+ ``INTERLOPER_MCP_ORG_ID`` is a local-development fallback that skips
60
+ authentication entirely.
61
+
62
+ Args:
63
+ store: The Store to resolve the token against.
64
+ token: Raw personal access token, or empty.
65
+ org_id: Organisation UUID string, or empty.
66
+
67
+ Returns:
68
+ The organisation UUID to scope all tool calls to.
69
+ """
70
+ if token:
71
+ resolved = store.resolve_token(token)
72
+ if resolved is None:
73
+ raise SystemExit("INTERLOPER_MCP_TOKEN is invalid, expired, or revoked")
74
+ profile, pat, role = resolved
75
+ logger.info("Authenticated as %s (role %s)", profile.email, role)
76
+ return pat.organisation_id
77
+ if org_id:
78
+ logger.warning("Running without authentication, scoped to org %s (INTERLOPER_MCP_ORG_ID)", org_id)
79
+ return UUID(org_id)
80
+ raise SystemExit("stdio transport needs INTERLOPER_MCP_TOKEN (or INTERLOPER_MCP_ORG_ID for local development)")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
@@ -0,0 +1,61 @@
1
+ """MCP server factory: FastMCP wired with PAT auth and the read-only tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from interloper.settings import McpSettings
6
+ from interloper_db import Store
7
+ from mcp.server.auth.settings import AuthSettings as McpAuthSettings
8
+ from mcp.server.fastmcp import FastMCP
9
+ from pydantic import AnyHttpUrl
10
+
11
+ from interloper_mcp.auth import PatVerifier
12
+ from interloper_mcp.tools import register_tools
13
+
14
+ SERVER_NAME = "interloper"
15
+
16
+ INSTRUCTIONS = (
17
+ "Read-only access to an interloper deployment: the catalog of available "
18
+ "component definitions, the organisation's collection (sources, "
19
+ "connections, destinations, jobs), asset lineage, run and backfill "
20
+ "monitoring, and analytics (freshness, coverage, history). All results "
21
+ "are scoped to the organisation of the authenticated token."
22
+ )
23
+
24
+
25
+ def create_mcp_server(settings: McpSettings, store: Store | None = None) -> FastMCP:
26
+ """Build the FastMCP server.
27
+
28
+ Args:
29
+ settings: The MCP settings (host, port, external URL).
30
+ store: When provided, requests authenticate as bearer PATs against
31
+ this store (the streamable HTTP mode). ``None`` builds an
32
+ unauthenticated server for the stdio transport, whose context is
33
+ seeded once at startup instead.
34
+
35
+ Returns:
36
+ The configured server with all read-only tools registered.
37
+ """
38
+ auth_kwargs: dict = {}
39
+ if store is not None:
40
+ # external_url doubles as issuer and resource URL in the RFC 9728
41
+ # protected-resource metadata; default to the local bind address so
42
+ # dev servers work without configuration.
43
+ base_url = settings.external_url or f"http://localhost:{settings.port}"
44
+ auth_kwargs = {
45
+ "token_verifier": PatVerifier(store),
46
+ "auth": McpAuthSettings(
47
+ issuer_url=AnyHttpUrl(base_url),
48
+ resource_server_url=AnyHttpUrl(base_url),
49
+ ),
50
+ }
51
+
52
+ mcp = FastMCP(
53
+ SERVER_NAME,
54
+ instructions=INSTRUCTIONS,
55
+ host=settings.host,
56
+ port=settings.port,
57
+ stateless_http=True,
58
+ **auth_kwargs,
59
+ )
60
+ register_tools(mcp)
61
+ return mcp
@@ -0,0 +1,125 @@
1
+ """MCP tool registration — thin wrappers over the shared read-only toolkit.
2
+
3
+ Every wrapper is one delegation line; the implementations live in
4
+ ``interloper_toolkit`` (shared with the ADK agent), and their LLM-facing
5
+ docstrings are adopted as the tool descriptions. FastMCP derives each tool's
6
+ input schema from the wrapper's signature and its output schema from the
7
+ typed ``<SuccessModel> | ToolError`` return annotation.
8
+
9
+ Deliberately read-only: no mutation (trigger/toggle/create) tools are
10
+ registered here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from interloper_toolkit import analytics, catalog, collection, lineage, scheduling
16
+ from interloper_toolkit import models as m
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ from interloper_mcp.context import get_ctx
20
+
21
+
22
+ def register_tools(mcp: FastMCP) -> None:
23
+ """Register the read-only interloper tools on the server.
24
+
25
+ Args:
26
+ mcp: The FastMCP server instance.
27
+ """
28
+ # -- Catalog ----------------------------------------------------------
29
+
30
+ @mcp.tool(description=catalog.list_definitions.__doc__)
31
+ def list_definitions(kind: str | None = None) -> m.DefinitionCounts | m.DefinitionList | m.ToolError:
32
+ return catalog.list_definitions(get_ctx(), kind)
33
+
34
+ @mcp.tool(description=catalog.get_definition.__doc__)
35
+ def get_definition(key: str) -> m.DefinitionDetail | m.ToolError:
36
+ return catalog.get_definition(get_ctx(), key)
37
+
38
+ @mcp.tool(description=catalog.get_asset_schema.__doc__)
39
+ def get_asset_schema(source_key: str, asset_key: str) -> m.AssetSchemaResult | m.ToolError:
40
+ return catalog.get_asset_schema(get_ctx(), source_key, asset_key)
41
+
42
+ @mcp.tool(description=catalog.search_fields.__doc__)
43
+ def search_fields(query: str) -> m.FieldSearchResult | m.ToolError:
44
+ return catalog.search_fields(get_ctx(), query)
45
+
46
+ @mcp.tool(description=catalog.compare_schemas.__doc__)
47
+ def compare_schemas(
48
+ source_key_a: str,
49
+ asset_key_a: str,
50
+ source_key_b: str,
51
+ asset_key_b: str,
52
+ ) -> m.SchemaComparison | m.ToolError:
53
+ return catalog.compare_schemas(get_ctx(), source_key_a, asset_key_a, source_key_b, asset_key_b)
54
+
55
+ # -- Collection -------------------------------------------------------
56
+
57
+ @mcp.tool(description=collection.list_components.__doc__)
58
+ def list_components(kind: str | None = None) -> m.ComponentCounts | m.ComponentList | m.ToolError:
59
+ return collection.list_components(get_ctx(), kind)
60
+
61
+ # -- Lineage ----------------------------------------------------------
62
+
63
+ @mcp.tool(description=lineage.get_upstream.__doc__)
64
+ def get_upstream(asset_id: str) -> m.UpstreamResult | m.ToolError:
65
+ return lineage.get_upstream(get_ctx(), asset_id)
66
+
67
+ @mcp.tool(description=lineage.get_downstream.__doc__)
68
+ def get_downstream(asset_id: str) -> m.DownstreamResult | m.ToolError:
69
+ return lineage.get_downstream(get_ctx(), asset_id)
70
+
71
+ @mcp.tool(description=lineage.get_full_lineage.__doc__)
72
+ def get_full_lineage(asset_id: str, direction: str = "upstream") -> m.LineageResult | m.ToolError:
73
+ return lineage.get_full_lineage(get_ctx(), asset_id, direction)
74
+
75
+ @mcp.tool(description=lineage.impact_analysis.__doc__)
76
+ def impact_analysis(asset_id: str) -> m.ImpactAnalysis | m.ToolError:
77
+ return lineage.impact_analysis(get_ctx(), asset_id)
78
+
79
+ @mcp.tool(description=lineage.cross_source_dependencies.__doc__)
80
+ def cross_source_dependencies() -> m.CrossSourceDependencies | m.ToolError:
81
+ return lineage.cross_source_dependencies(get_ctx())
82
+
83
+ # -- Scheduling (read-only) --------------------------------------------
84
+
85
+ @mcp.tool(description=scheduling.list_jobs.__doc__)
86
+ def list_jobs() -> m.JobList | m.ToolError:
87
+ return scheduling.list_jobs(get_ctx())
88
+
89
+ @mcp.tool(description=scheduling.get_job_health.__doc__)
90
+ def get_job_health(component_id: str) -> m.JobHealth | m.ToolError:
91
+ return scheduling.get_job_health(get_ctx(), component_id)
92
+
93
+ @mcp.tool(description=scheduling.list_recent_runs.__doc__)
94
+ def list_recent_runs(
95
+ component_id: str | None = None,
96
+ status: str | None = None,
97
+ limit: int = 20,
98
+ ) -> m.RunList | m.ToolError:
99
+ return scheduling.list_recent_runs(get_ctx(), component_id, status, limit)
100
+
101
+ @mcp.tool(description=scheduling.get_run_detail.__doc__)
102
+ def get_run_detail(run_id: str) -> m.RunDetail | m.ToolError:
103
+ return scheduling.get_run_detail(get_ctx(), run_id)
104
+
105
+ @mcp.tool(description=scheduling.list_failures.__doc__)
106
+ def list_failures(limit: int = 20) -> m.FailureList | m.ToolError:
107
+ return scheduling.list_failures(get_ctx(), limit)
108
+
109
+ @mcp.tool(description=scheduling.list_backfills.__doc__)
110
+ def list_backfills(active_only: bool = True) -> m.BackfillList | m.ToolError:
111
+ return scheduling.list_backfills(get_ctx(), active_only)
112
+
113
+ # -- Analytics ---------------------------------------------------------
114
+
115
+ @mcp.tool(description=analytics.run_history_summary.__doc__)
116
+ def run_history_summary(component_id: str | None = None, days: int = 7) -> m.RunHistorySummary | m.ToolError:
117
+ return analytics.run_history_summary(get_ctx(), component_id, days)
118
+
119
+ @mcp.tool(description=analytics.partition_coverage.__doc__)
120
+ def partition_coverage(component_id: str, start_date: str, end_date: str) -> m.PartitionCoverage | m.ToolError:
121
+ return analytics.partition_coverage(get_ctx(), component_id, start_date, end_date)
122
+
123
+ @mcp.tool(description=analytics.freshness_check.__doc__)
124
+ def freshness_check() -> m.FreshnessReport | m.ToolError:
125
+ return analytics.freshness_check(get_ctx())