Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,10 @@ Cada ferramenta de cadastro aceita um parâmetro `env`:

## Instalação

Requer [`uv`](https://docs.astral.sh/uv/).

```bash
pip install -r requirements.txt
uv sync
```

---
Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[project]
name = "databasis-mcp"
version = "0.1.0"
description = "MCP server for Base dos Dados backend API"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastmcp>=2.0",
"google-cloud-bigquery>=3.41.0",
"requests>=2.31",
]
3 changes: 0 additions & 3 deletions requirements.txt

This file was deleted.

151 changes: 150 additions & 1 deletion src/databasis_mcp/tools/prefect.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import requests

from .._app import mcp
from .._app import URLS, mcp


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -59,6 +59,18 @@ def _prefect_post(path: str, body: dict) -> Any:
return r.json()


def _prefect_get(path: str) -> Any:
"""GET from a Prefect 3 REST endpoint (e.g. '/deployments/name/<flow>/<deploy>')."""
r = requests.get(
f"{PREFECT_URL}{path}",
headers={"Authorization": f"Bearer {_prefect_key()}"},
timeout=60,
)
if not r.ok:
raise RuntimeError(f"HTTP {r.status_code}:\n{r.text}")
return r.json()


_PREFECT_PAGE_MAX = 200


Expand Down Expand Up @@ -214,6 +226,143 @@ def get_failed_flow_runs(
return result


@mcp.tool()
def run_deployment(
deployment_name: str,
parameters: dict | None = None,
) -> dict:
"""Trigger a Prefect 3 flow run from a deployment (creates a run immediately).

Creates a Scheduled flow run that the deployment's worker picks up and runs.
`parameters` overrides the deployment's default parameter values; omit it (or
pass None) to run with the deployment defaults.

Args:
deployment_name: '<flow_name>/<deployment_name>', e.g.
'br_me_siconfi/br_me_siconfi_flow'. Must contain exactly
one '/'.
parameters: Flow parameters overriding the deployment defaults. None = {}.

Returns:
On success, a dict with 'flow_run_id', 'name', 'state', and 'ui_url'.
On failure, a dict with an 'error' key describing the problem.
"""
parts = deployment_name.split("/")
if len(parts) != 2 or not parts[0] or not parts[1]:
return {
"error": (
"deployment_name must be '<flow_name>/<deployment_name>' with "
f"exactly one '/'; got {deployment_name!r}"
)
}
flow_name, deploy_name = parts

try:
deployment = _prefect_get(f"/deployments/name/{flow_name}/{deploy_name}")
deployment_id = deployment["id"]
run = _prefect_post(
f"/deployments/{deployment_id}/create_flow_run",
{"parameters": parameters or {}},
)
except Exception as e: # surface any HTTP/network error as a dict, never raise
return {"error": str(e)}

flow_run_id = run.get("id")
return {
"flow_run_id": flow_run_id,
"name": run.get("name"),
"state": (run.get("state") or {}).get("name"),
"ui_url": f"https://prefect3.basedosdados.org/v2/runs/flow-run/{flow_run_id}",
}


@mcp.tool()
def set_deployment_schedule_active(
flow_name: str,
active: bool,
env: str = "prod",
) -> dict:
"""Arm or disarm a deployment's schedule (the API equivalent of the admin tick).

A merge to main deploys a flow **paused**, and the backend registers it with
`is_schedule_active=False`. Arming means three things together: update that
stored flag, stamp `reactivated_at`, and unpause the deployment in Prefect 3.
This calls `POST /admin-tools/set-schedule-active/`, which does all three.

Do not reach for the Prefect API directly to unpause. `sync-deployments` —
which CI runs on every merge to main — re-enforces the stored
`is_schedule_active` state, so a Prefect-only unpause looks armed and then
silently re-pauses at the next unrelated merge.

Setting the state a flow is already in is a safe no-op (`action="no_change"`)
and never touches Prefect, so it doubles as a way to read the current state.

Before arming a pipeline for the first time, know what the first run does: it
is the first-ever execution of the prod upload, and for a `part_bdpro` table
also of the BigQuery Row Access Policies. Watch that run.

Args:
flow_name: Prefect's **bare deployment name**, e.g.
'au_rba_statistical_tables_flow' — not '<flow>/<deployment>'. The
backend stores whatever `/deployments/filter` returns as `name`,
which is the same string shown in the Django admin's `flow_name`
column.
active: True to arm (unpause), False to disarm (pause). Disarming is the
kill switch for a misbehaving pipeline.
env: Backend to target: 'prod' (default), 'staging', 'dev' or 'local'.

Returns:
On success, a dict with 'flow_name', 'deployment_id',
'is_schedule_active', 'reactivated_at' and 'action' (one of 'activated',
'disabled', 'no_change').
On failure, a dict with an 'error' key describing the problem.
"""
base = URLS.get(env)
if base is None:
return {"error": f"unknown env {env!r}; use one of {sorted(URLS)}"}

try:
r = requests.post(
f"{base}/admin-tools/set-schedule-active/",
json={"flow_name": flow_name, "is_schedule_active": active},
headers={"Authorization": f"Bearer {_prefect_key()}"},
timeout=60,
)
except Exception as e: # surface any network error as a dict, never raise
return {"error": str(e)}

try:
body = r.json()
except ValueError:
# Django's own HTML error page rather than this view's JSON, so the
# route is not there. Worth distinguishing: read as "unknown flow", a
# missing *route* looks like a missing DisabledFlowSchedule row, which
# is a different and far more alarming diagnosis — it would imply an
# armed pipeline is about to be re-paused by the next backend sync.
return {
"error": (
f"HTTP {r.status_code} from {base}/admin-tools/set-schedule-active/ "
"with a non-JSON body: the endpoint is not deployed on this backend. "
"It ships in basedosdados/backend#1060. This says nothing about "
"whether the flow is armed — read `paused` on the Prefect deployment."
)
}

if r.status_code == 404:
return {
"error": (
f"Unknown flow {flow_name!r} on {env}: no DisabledFlowSchedule row. "
"Check the name is Prefect's bare deployment name (e.g. "
"'au_rba_statistical_tables_flow', not '<flow>/<deployment>'). Rows "
"are created by the backend sync that CI runs after each deploy to "
"main."
)
}
if not r.ok:
return {"error": f"HTTP {r.status_code}: {json.dumps(body)[:300]}"}
return body


# ---------------------------------------------------------------------------
# Prefect trigger
# ---------------------------------------------------------------------------
Expand Down
Loading