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.
- interloper_mcp-0.42.0/PKG-INFO +95 -0
- interloper_mcp-0.42.0/README.md +81 -0
- interloper_mcp-0.42.0/pyproject.toml +42 -0
- interloper_mcp-0.42.0/src/interloper_mcp/__init__.py +1 -0
- interloper_mcp-0.42.0/src/interloper_mcp/auth.py +55 -0
- interloper_mcp-0.42.0/src/interloper_mcp/context.py +82 -0
- interloper_mcp-0.42.0/src/interloper_mcp/main.py +84 -0
- interloper_mcp-0.42.0/src/interloper_mcp/server.py +61 -0
- interloper_mcp-0.42.0/src/interloper_mcp/tools.py +125 -0
|
@@ -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())
|