levain-sdk 0.1.0__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.
- levain_sdk/__init__.py +21 -0
- levain_sdk/_mcp.py +30 -0
- levain_sdk/agents_tools/__init__.py +20 -0
- levain_sdk/agents_tools/mcp.py +119 -0
- levain_sdk/app_data/__init__.py +1 -0
- levain_sdk/app_data/mcp.py +225 -0
- levain_sdk/app_data/query.py +339 -0
- levain_sdk/artifacts/__init__.py +21 -0
- levain_sdk/artifacts/_publish_raw_source.py +86 -0
- levain_sdk/artifacts/_staging.py +194 -0
- levain_sdk/artifacts/mcp.py +302 -0
- levain_sdk/catalog.py +21 -0
- levain_sdk/claude.py +1238 -0
- levain_sdk/crawler/__init__.py +971 -0
- levain_sdk/errors.py +53 -0
- levain_sdk/formatting.py +40 -0
- levain_sdk/git.py +608 -0
- levain_sdk/github_tools/__init__.py +5 -0
- levain_sdk/github_tools/mcp.py +628 -0
- levain_sdk/handlers.py +198 -0
- levain_sdk/hermes.py +590 -0
- levain_sdk/hermes_driver.py +231 -0
- levain_sdk/hermes_memory.py +169 -0
- levain_sdk/image_tools/__init__.py +1 -0
- levain_sdk/image_tools/mcp.py +85 -0
- levain_sdk/integrations.py +283 -0
- levain_sdk/mcp_registry.py +284 -0
- levain_sdk/mcp_stdio.py +103 -0
- levain_sdk/memory.py +48 -0
- levain_sdk/memory_tools/__init__.py +13 -0
- levain_sdk/memory_tools/mcp.py +403 -0
- levain_sdk/messages.py +151 -0
- levain_sdk/models.py +645 -0
- levain_sdk/models_catalog.md +205 -0
- levain_sdk/platform_actions/__init__.py +39 -0
- levain_sdk/platform_actions/google_ads.py +235 -0
- levain_sdk/platform_actions/meta_ads.py +102 -0
- levain_sdk/platform_actions/shopify.py +147 -0
- levain_sdk/platform_actions/stripe.py +137 -0
- levain_sdk/progress_tools/__init__.py +1 -0
- levain_sdk/progress_tools/mcp.py +81 -0
- levain_sdk/prompts.py +31 -0
- levain_sdk/py.typed +0 -0
- levain_sdk/reducers.py +69 -0
- levain_sdk/review/__init__.py +0 -0
- levain_sdk/review/github.py +730 -0
- levain_sdk/review/mcp.py +602 -0
- levain_sdk/review/store.py +905 -0
- levain_sdk/sandbox_api.py +69 -0
- levain_sdk/sessions_tools/__init__.py +0 -0
- levain_sdk/sessions_tools/mcp.py +88 -0
- levain_sdk/shell.py +156 -0
- levain_sdk/slack/__init__.py +1 -0
- levain_sdk/slack/mcp.py +274 -0
- levain_sdk/wiki/__init__.py +15 -0
- levain_sdk/wiki/index.py +366 -0
- levain_sdk/wiki/mcp.py +424 -0
- levain_sdk/wiki/scoring.py +173 -0
- levain_sdk-0.1.0.dist-info/METADATA +64 -0
- levain_sdk-0.1.0.dist-info/RECORD +62 -0
- levain_sdk-0.1.0.dist-info/WHEEL +4 -0
- levain_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
levain_sdk/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Levain Recipe SDK — helpers for recipe node functions.
|
|
2
|
+
|
|
3
|
+
Zero dependency on levain. Levain depends on the SDK,
|
|
4
|
+
not the other way around.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
AgentError,
|
|
9
|
+
GitHubAPIError,
|
|
10
|
+
KneadError,
|
|
11
|
+
OperationTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
from .shell import run_checks
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AgentError",
|
|
17
|
+
"GitHubAPIError",
|
|
18
|
+
"KneadError",
|
|
19
|
+
"OperationTimeoutError",
|
|
20
|
+
"run_checks",
|
|
21
|
+
]
|
levain_sdk/_mcp.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared helper for FastMCP → ClaudeAgentOptions config.
|
|
2
|
+
|
|
3
|
+
Every platform MCP server (``levain-build``, ``knead-review``)
|
|
4
|
+
needs the same dance: pull the FastMCP
|
|
5
|
+
instance's private ``_mcp_server`` attribute and wrap it in the
|
|
6
|
+
``McpSdkServerConfig`` shape the SDK expects. Kept central so a
|
|
7
|
+
FastMCP API change is patched in one place, not three.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sdk_mcp_config(mcp_instance: Any, server_name: str) -> dict[str, Any]:
|
|
16
|
+
"""Return an in-process MCP server config for ``ClaudeAgentOptions``.
|
|
17
|
+
|
|
18
|
+
``_mcp_server`` is a private attribute of FastMCP; re-verify
|
|
19
|
+
it exists when bumping the ``fastmcp`` pin (tested against
|
|
20
|
+
3.1.1). A missing attribute means the FastMCP internals
|
|
21
|
+
moved and the caller needs to track the new shape.
|
|
22
|
+
"""
|
|
23
|
+
server = getattr(mcp_instance, "_mcp_server", None)
|
|
24
|
+
if server is None or not hasattr(server, "run"):
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
f"FastMCP internal API changed — _mcp_server no longer "
|
|
27
|
+
f"exists or changed type (server={server_name!r}). Pin "
|
|
28
|
+
f"fastmcp to the version this was tested against."
|
|
29
|
+
)
|
|
30
|
+
return {"type": "sdk", "name": server_name, "instance": server}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agent-to-agent calling tools (the ``levain-agents`` MCP).
|
|
2
|
+
|
|
3
|
+
The ``call_agent`` tool lets an agent delegate a task to one of the
|
|
4
|
+
workspace's other agents. The tool only *signals* the request; the
|
|
5
|
+
recipe's worker-side ``CallAgent`` node performs the dispatch via
|
|
6
|
+
the harness A2A machinery (:mod:`levain.agent.harness.a2a`), so the
|
|
7
|
+
untrusted sandbox never reaches dispatch directly.
|
|
8
|
+
|
|
9
|
+
Lives in the customer SDK (moved from ``platform_sdk`` so it works on
|
|
10
|
+
the customer image — the managed Levain assistant is the first
|
|
11
|
+
consumer there, and A2A is a customer-facing capability anyway). The
|
|
12
|
+
worker still gates who may actually be called: dispatch authorizes
|
|
13
|
+
from the caller run's own context, never from the sandbox's claims.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Env var the harness injects per turn with the JSON list of agents
|
|
17
|
+
# the Levain agent may call (base64). Read by the recipe's Respond
|
|
18
|
+
# node to render the choices into the prompt — discovery is
|
|
19
|
+
# prompt-side, not a tool round-trip.
|
|
20
|
+
ENV_CALLABLE_AGENTS = "LEVAIN_CALLABLE_AGENTS_B64"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""FastMCP server exposing the agent-to-agent ``call_agent`` tool.
|
|
2
|
+
|
|
3
|
+
Runs in-process inside the sandbox via stdio. Lets an agent
|
|
4
|
+
delegate a task to one of the workspace's other agents.
|
|
5
|
+
|
|
6
|
+
This is a *signal* tool, not an RPC client: calling it only records
|
|
7
|
+
the requested ``(agent, request)`` in a signal file. The Levain
|
|
8
|
+
recipe's ``Respond`` node reads that signal after the agent's SDK
|
|
9
|
+
session returns (:func:`consume_call`), routes the graph to a
|
|
10
|
+
``CallAgent`` node, and that node — running in the **trusted
|
|
11
|
+
worker** — performs the dispatch via the harness A2A machinery and
|
|
12
|
+
feeds the result back into the next ``Respond`` turn. The untrusted
|
|
13
|
+
sandbox never reaches dispatch directly and never supplies the
|
|
14
|
+
caller's identity; the worker authorizes from the caller run's own
|
|
15
|
+
context. The tool call itself only records a module-level signal
|
|
16
|
+
that the node wrapper consumes after the agent returns.
|
|
17
|
+
|
|
18
|
+
Discovery is prompt-side: the worker injects the callable-agents
|
|
19
|
+
list into the ``Respond`` prompt, so there's no ``list_agents``
|
|
20
|
+
tool here.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from fastmcp import FastMCP
|
|
32
|
+
|
|
33
|
+
from levain_sdk._mcp import sdk_mcp_config
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("knead")
|
|
36
|
+
|
|
37
|
+
mcp = FastMCP("levain-agents")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class _AgentCall:
|
|
42
|
+
"""A delegation the Levain agent requested this turn."""
|
|
43
|
+
|
|
44
|
+
slug: str
|
|
45
|
+
request: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# File-backed signal, written by ``call_agent`` and reset by ``init``
|
|
49
|
+
# on each Respond dispatch so a stale request can't leak across
|
|
50
|
+
# turns. Single-slot: the tool's contract is "call it, then wrap up",
|
|
51
|
+
# so at most one delegation per session (the recipe loops for more).
|
|
52
|
+
# A file rather than a module global because the server doesn't
|
|
53
|
+
# always share a process with the node that reads the signal: the
|
|
54
|
+
# Claude engine runs it in-process, but the Hermes engine runs it as
|
|
55
|
+
# a stdio subprocess (``levain_sdk.mcp_stdio``). Same pattern as the
|
|
56
|
+
# ``levain-memory`` queue.
|
|
57
|
+
_SIGNAL_PATH = Path("/tmp/levain-agents/call.json")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def init(repo_root: str = "", branch: str = "", iteration: int = 0) -> None:
|
|
61
|
+
"""Reset the pending-call signal for this Respond dispatch."""
|
|
62
|
+
del repo_root, branch, iteration
|
|
63
|
+
_SIGNAL_PATH.unlink(missing_ok=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def consume_call() -> _AgentCall | None:
|
|
67
|
+
"""Return the captured delegation, if any, and clear it."""
|
|
68
|
+
try:
|
|
69
|
+
raw = _SIGNAL_PATH.read_text(encoding="utf-8")
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return None
|
|
72
|
+
_SIGNAL_PATH.unlink(missing_ok=True)
|
|
73
|
+
try:
|
|
74
|
+
record = json.loads(raw)
|
|
75
|
+
except ValueError:
|
|
76
|
+
log.warning("CALL_AGENT unparseable signal file; ignoring")
|
|
77
|
+
return None
|
|
78
|
+
return _AgentCall(
|
|
79
|
+
slug=str(record.get("slug") or ""),
|
|
80
|
+
request=str(record.get("request") or ""),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@mcp.tool
|
|
85
|
+
async def call_agent(agent: str, request: str) -> dict[str, Any]:
|
|
86
|
+
"""Hand a task to one of the workspace's other agents.
|
|
87
|
+
|
|
88
|
+
Use the ``agent`` slug from the list of available agents in your
|
|
89
|
+
instructions. ``request`` is what you want that agent to do, in
|
|
90
|
+
plain language — it's delivered as the agent's prompt.
|
|
91
|
+
|
|
92
|
+
Calling this **ends your current turn**: the agent runs and you
|
|
93
|
+
are re-invoked with its result, which you then use to answer.
|
|
94
|
+
The agent may take several minutes depending on the task, so
|
|
95
|
+
close your turn with a short note telling the user what you
|
|
96
|
+
handed off and that the answer is coming — don't also answer
|
|
97
|
+
the question yourself.
|
|
98
|
+
Call it only when an available agent clearly fits the task; do
|
|
99
|
+
not invent a slug.
|
|
100
|
+
"""
|
|
101
|
+
slug = (agent or "").strip()
|
|
102
|
+
record = {"slug": slug, "request": (request or "").strip()}
|
|
103
|
+
_SIGNAL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
_SIGNAL_PATH.write_text(json.dumps(record), encoding="utf-8")
|
|
105
|
+
log.info("CALL_AGENT delegation requested (agent=%r)", slug)
|
|
106
|
+
return {
|
|
107
|
+
"ok": True,
|
|
108
|
+
"message": (
|
|
109
|
+
f"Delegating to {slug!r}. Stop here — you'll be re-invoked "
|
|
110
|
+
"with the agent's result, which can take several minutes. "
|
|
111
|
+
"End your turn with a one-line heads-up that you've handed "
|
|
112
|
+
"this off; don't answer the question yourself."
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_agents_mcp_server() -> dict[str, Any]:
|
|
118
|
+
"""Return an in-process MCP server config for ClaudeAgentOptions."""
|
|
119
|
+
return sdk_mcp_config(mcp, "levain-agents")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Synced platform data — workspace-scoped query tools for agents."""
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""FastMCP server exposing synced platform-app data to agents.
|
|
2
|
+
|
|
3
|
+
A recipe opts in via ``@mcp_servers ["levain-app-data"]``. The harness injects,
|
|
4
|
+
on the attached platform-app integrations' ``metadata.app_data``, a **direct
|
|
5
|
+
ClickHouse connection** for this run: the workspace's readonly CH account
|
|
6
|
+
(``agent_user_<ws>``) plus a short-lived workspace JWT used as its password.
|
|
7
|
+
ClickHouse validates that JWT via its ``levain_agent_auth`` callback and applies
|
|
8
|
+
the account's row policy, so queries are workspace-scoped **at the database** —
|
|
9
|
+
the agent sees only its own workspace's rows, over a read-only, capped account.
|
|
10
|
+
|
|
11
|
+
Tables cover the connected apps: Stripe (charges, subscriptions, invoices),
|
|
12
|
+
Shopify (orders, checkouts, products), app events, and daily ads campaign
|
|
13
|
+
metrics. ``list_app_tables`` returns the authoritative schema.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fastmcp import FastMCP
|
|
25
|
+
|
|
26
|
+
from .._mcp import sdk_mcp_config
|
|
27
|
+
from ..integrations import load_attached
|
|
28
|
+
from .query import (
|
|
29
|
+
AppDataUnavailable,
|
|
30
|
+
QueryError,
|
|
31
|
+
build_sql,
|
|
32
|
+
probe_coverage,
|
|
33
|
+
run_query,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP("levain-app-data")
|
|
39
|
+
|
|
40
|
+
# ClickHouse connection + shipped table manifest for this run.
|
|
41
|
+
_conn: dict[str, Any] = {}
|
|
42
|
+
_tables: dict[str, Any] = {}
|
|
43
|
+
# Row count + time span per table, probed once per run (see ``_coverage``).
|
|
44
|
+
_coverage_cache: dict[str, dict[str, Any]] | None = None
|
|
45
|
+
_coverage_lock = asyncio.Lock()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def init(
|
|
49
|
+
repo_root: str,
|
|
50
|
+
branch: str = "",
|
|
51
|
+
iteration: int = 0,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Locate the ClickHouse connection from attached integrations."""
|
|
54
|
+
global _conn, _tables, _coverage_cache
|
|
55
|
+
_conn = {}
|
|
56
|
+
_tables = {}
|
|
57
|
+
_coverage_cache = None
|
|
58
|
+
for integ in load_attached():
|
|
59
|
+
app_data = (integ.get("metadata") or {}).get("app_data") or {}
|
|
60
|
+
if app_data.get("ch_host") and app_data.get("ch_token"):
|
|
61
|
+
_conn = {
|
|
62
|
+
"ch_host": app_data["ch_host"],
|
|
63
|
+
"ch_port": app_data.get("ch_port", 8123),
|
|
64
|
+
"ch_database": app_data.get("ch_database", "default"),
|
|
65
|
+
"ch_user": app_data["ch_user"],
|
|
66
|
+
"ch_token": app_data["ch_token"],
|
|
67
|
+
"ch_distributed": bool(app_data.get("ch_distributed")),
|
|
68
|
+
}
|
|
69
|
+
_tables = app_data.get("tables") or {}
|
|
70
|
+
return
|
|
71
|
+
# Fallback for platform recipes / tests that inject a connection directly.
|
|
72
|
+
host = os.environ.get("LEVAIN_APP_DATA_CH_HOST", "")
|
|
73
|
+
if host:
|
|
74
|
+
_conn = {
|
|
75
|
+
"ch_host": host,
|
|
76
|
+
"ch_port": int(os.environ.get("LEVAIN_APP_DATA_CH_PORT", "8123")),
|
|
77
|
+
"ch_database": os.environ.get("LEVAIN_APP_DATA_CH_DB", "default"),
|
|
78
|
+
"ch_user": os.environ.get("LEVAIN_APP_DATA_CH_USER", ""),
|
|
79
|
+
"ch_token": os.environ.get("LEVAIN_APP_DATA_CH_TOKEN", ""),
|
|
80
|
+
"ch_distributed": os.environ.get("LEVAIN_APP_DATA_CH_DIST") == "1",
|
|
81
|
+
}
|
|
82
|
+
raw = os.environ.get("LEVAIN_APP_DATA_TABLES", "")
|
|
83
|
+
_tables = json.loads(raw) if raw else {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _require_conn() -> None:
|
|
87
|
+
if not _conn:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"app-data is not available on this run — connect a platform "
|
|
90
|
+
"app (Stripe, Shopify, Google Ads, Meta Ads) and attach it to "
|
|
91
|
+
"this agent"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _coverage() -> dict[str, dict[str, Any]]:
|
|
96
|
+
"""Cached per-table row count and time span for this run."""
|
|
97
|
+
global _coverage_cache
|
|
98
|
+
async with _coverage_lock:
|
|
99
|
+
if _coverage_cache is None:
|
|
100
|
+
_coverage_cache = await probe_coverage(_conn, _tables)
|
|
101
|
+
return _coverage_cache
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcp.tool()
|
|
105
|
+
async def list_app_tables() -> dict[str, Any]:
|
|
106
|
+
"""Describe the queryable synced-data tables, columns, and coverage.
|
|
107
|
+
|
|
108
|
+
Call this first to learn table names, columns, and each table's time
|
|
109
|
+
column before using ``query_app_data``. Each table also carries its
|
|
110
|
+
``row_count`` and the ``first_at``/``last_at`` timestamps it actually
|
|
111
|
+
holds — pick a time window inside that span instead of guessing one, and
|
|
112
|
+
skip tables whose ``row_count`` is 0.
|
|
113
|
+
"""
|
|
114
|
+
_require_conn()
|
|
115
|
+
coverage = await _coverage()
|
|
116
|
+
tables = {
|
|
117
|
+
name: {**spec, **coverage[name]} if name in coverage else spec
|
|
118
|
+
for name, spec in _tables.items()
|
|
119
|
+
}
|
|
120
|
+
return {"tables": tables}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
async def query_app_data(
|
|
125
|
+
table: str,
|
|
126
|
+
columns: list[str] | None = None,
|
|
127
|
+
filters: list[dict[str, Any]] | None = None,
|
|
128
|
+
aggregates: list[dict[str, Any]] | None = None,
|
|
129
|
+
group_by: list[str] | None = None,
|
|
130
|
+
order_by: list[dict[str, Any]] | None = None,
|
|
131
|
+
since: str | None = None,
|
|
132
|
+
until: str | None = None,
|
|
133
|
+
limit: int = 100,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Query a synced-data table (workspace-scoped, read-only).
|
|
136
|
+
|
|
137
|
+
Choose ``since``/``until`` from the coverage ``list_app_tables`` reports
|
|
138
|
+
for the table, not from an assumed range. A result with ``row_count`` 0
|
|
139
|
+
carries a ``hint`` with the table's real row count and time span — read
|
|
140
|
+
it and correct the query, rather than retrying another guess.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
table: one of the tables from ``list_app_tables``.
|
|
144
|
+
columns: columns to select (omit for all, or rely on aggregates).
|
|
145
|
+
filters: ``[{"column": ..., "op": "="|"!="|">"|">="|"<"|"<="|"in"|
|
|
146
|
+
"like", "value": ...}]``.
|
|
147
|
+
aggregates: ``[{"fn": "count"|"sum"|"avg"|"min"|"max", "column": ...,
|
|
148
|
+
"alias": ...}]`` — combine with ``group_by`` for rollups.
|
|
149
|
+
group_by: grouping columns. When set, ``columns`` may list only
|
|
150
|
+
group-by keys — bare non-grouped columns are dropped (ClickHouse
|
|
151
|
+
requires every selected column to be a group key or aggregated).
|
|
152
|
+
order_by: ``[{"column": ..., "desc": true}]``. When ``group_by`` is
|
|
153
|
+
set, each column must be a group-by key or an aggregate alias.
|
|
154
|
+
since/until: ISO bounds on the table's time column.
|
|
155
|
+
limit: max rows (≤1000).
|
|
156
|
+
|
|
157
|
+
Example — failed charge volume by week:
|
|
158
|
+
``query_app_data(table="stripe_charges",
|
|
159
|
+
filters=[{"column": "status", "value": "failed"}],
|
|
160
|
+
aggregates=[{"fn": "count", "alias": "n"},
|
|
161
|
+
{"fn": "sum", "column": "amount", "alias": "total"}],
|
|
162
|
+
group_by=["currency"], since="2026-05-01")``
|
|
163
|
+
"""
|
|
164
|
+
_require_conn()
|
|
165
|
+
spec = _tables.get(table)
|
|
166
|
+
if spec is None:
|
|
167
|
+
return {"error": f"unknown table {table!r}; one of {sorted(_tables)}"}
|
|
168
|
+
resolved = f"{table}_distributed" if _conn["ch_distributed"] else table
|
|
169
|
+
try:
|
|
170
|
+
sql, params = build_sql(
|
|
171
|
+
spec,
|
|
172
|
+
resolved,
|
|
173
|
+
columns=columns or [],
|
|
174
|
+
filters=filters or [],
|
|
175
|
+
aggregates=aggregates or [],
|
|
176
|
+
group_by=group_by or [],
|
|
177
|
+
order_by=order_by or [],
|
|
178
|
+
since=since,
|
|
179
|
+
until=until,
|
|
180
|
+
limit=limit,
|
|
181
|
+
)
|
|
182
|
+
except QueryError as e:
|
|
183
|
+
# Ship the table's schema with the rejection so the corrected retry
|
|
184
|
+
# needs no extra ``list_app_tables`` round trip.
|
|
185
|
+
return {
|
|
186
|
+
"error": str(e),
|
|
187
|
+
"table": table,
|
|
188
|
+
"time_column": spec["time_column"],
|
|
189
|
+
"columns": spec["columns"],
|
|
190
|
+
}
|
|
191
|
+
try:
|
|
192
|
+
rows = await run_query(_conn, sql, params)
|
|
193
|
+
except (AppDataUnavailable, QueryError) as e:
|
|
194
|
+
return {"error": str(e)}
|
|
195
|
+
if rows:
|
|
196
|
+
return {"rows": rows, "row_count": len(rows)}
|
|
197
|
+
# Empty means the filters/window missed, not that the table is empty —
|
|
198
|
+
# ship what the table actually holds so the retry is corrected in one
|
|
199
|
+
# step instead of guessing another window.
|
|
200
|
+
result: dict[str, Any] = {"rows": rows, "row_count": 0}
|
|
201
|
+
covered = (await _coverage()).get(table)
|
|
202
|
+
if covered:
|
|
203
|
+
if covered.get("row_count"):
|
|
204
|
+
message = (
|
|
205
|
+
"no rows matched — the filters or time window are off, not "
|
|
206
|
+
"the table. Correct them from this coverage rather than "
|
|
207
|
+
"retrying another guess"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
message = (
|
|
211
|
+
"this table has nothing synced yet — no filter or window "
|
|
212
|
+
"change will return rows; try a different table"
|
|
213
|
+
)
|
|
214
|
+
result["hint"] = {
|
|
215
|
+
"message": message,
|
|
216
|
+
"table": table,
|
|
217
|
+
"time_column": spec["time_column"],
|
|
218
|
+
**covered,
|
|
219
|
+
}
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def create_app_data_mcp_server() -> dict[str, Any]:
|
|
224
|
+
"""Registry factory — see ``levain_sdk.mcp_registry``."""
|
|
225
|
+
return sdk_mcp_config(mcp, "levain-app-data")
|