Skip to content

Commit 2c9fb79

Browse files
joaodaherclaude
andcommitted
feat(onboarding): scaffold onboarding package with init and state tools
Adds the foundation for AI-driven dbt platform onboarding. An LLM agent can now start or resume an onboarding session and check its progress — all without touching any UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 409354b commit 2c9fb79

17 files changed

Lines changed: 597 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Enhancement or New Feature
2+
body: Add onboarding package scaffold and init/state tools (CC-3674, CC-3675)
3+
time: 2026-06-08T12:01:13.725527-03:00

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ Allowing your client to utilize dbt commands through the MCP tooling could modif
8282

8383
To learn more about the dbt Administrative API, click [here](https://docs.getdbt.com/docs/dbt-cloud-apis/admin-cloud-api).
8484
- `cancel_job_run`: Cancels a running job.
85+
- `dbt_admin_onboarding_init`: Starts or resumes an onboarding session; returns session ID, current phase, and decision points.
86+
- `dbt_admin_onboarding_state`: Returns the current onboarding session phase and server-side applied resource progress.
8587
- `get_job_details`: Gets job configuration including triggers, schedule, and dbt commands.
8688
- `get_job_run_details`: Gets run details including status, timing, steps, and artifacts.
8789
- `get_job_run_error`: Gets error and/or warning details for a job run; option to include or show warnings only.

src/dbt_mcp/dbt_admin/onboarding/__init__.py

Whitespace-only changes.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any
5+
6+
import httpx
7+
8+
from dbt_mcp.config.config_providers import AdminApiConfig, ConfigProvider
9+
from dbt_mcp.errors import AdminAPIError, InvalidParameterError
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class OnboardingClient:
15+
"""HTTP client for the dbt platform onboarding API.
16+
17+
Reuses AdminApiConfig (same base URL + auth headers) since the onboarding
18+
endpoints live under /api/v3/accounts/{account_id}/onboarding/.
19+
"""
20+
21+
def __init__(self, config_provider: ConfigProvider[AdminApiConfig]) -> None:
22+
self.config_provider = config_provider
23+
24+
async def get_state(self, account_id: int) -> dict[str, Any]:
25+
"""Fetch the server-side onboarding state for the account.
26+
27+
Returns the OnboardingSession row: applied resource IDs and status.
28+
"""
29+
config = await self.config_provider.get_config()
30+
url = f"{config.url}/api/v3/accounts/{account_id}/onboarding/state/"
31+
headers = {
32+
"Content-Type": "application/json",
33+
"Accept": "application/json",
34+
} | config.headers_provider.get_headers()
35+
36+
try:
37+
async with httpx.AsyncClient() as client:
38+
response = await client.get(
39+
url, headers=headers, follow_redirects=True
40+
)
41+
response.raise_for_status()
42+
return response.json()
43+
except httpx.HTTPStatusError as e:
44+
if 400 <= e.response.status_code < 500:
45+
raise InvalidParameterError(
46+
f"Onboarding state request failed ({e.response.status_code}) "
47+
f"for account {account_id}"
48+
) from e
49+
raise AdminAPIError(
50+
f"Onboarding state request failed ({e.response.status_code}) "
51+
f"for account {account_id}"
52+
) from e
53+
except httpx.HTTPError as e:
54+
raise AdminAPIError(
55+
f"Onboarding state request failed for account {account_id}"
56+
) from e
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
5+
6+
@dataclass
7+
class DecisionPoint:
8+
"""A single decision the user must make during onboarding.
9+
10+
Populated in Phase 0+ tickets as each resource type is added to the flow.
11+
The LLM proposes values; the user confirms every one.
12+
"""
13+
14+
key: str
15+
label: str
16+
description: str
17+
sensitive: bool = False
18+
rationale_hint: str = ""
19+
examples: list[str] = field(default_factory=list)
20+
21+
22+
def get_decision_points() -> list[DecisionPoint]:
23+
"""Return the current set of decision points for the onboarding flow.
24+
25+
Empty in the Foundation phase (CC-3674/CC-3675). Decision points are added
26+
incrementally in Phase 0 (account), Phase 1 (project), Phase 2 (environment),
27+
and Phase 3 (job) tickets.
28+
"""
29+
return []
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Any
2+
3+
from pydantic import BaseModel
4+
5+
from dbt_mcp.dbt_admin.onboarding.decision_points import DecisionPoint
6+
7+
8+
class OnboardingInitResult(BaseModel):
9+
session_id: str
10+
phase: str
11+
account_id: int
12+
decision_points: list[dict[str, Any]]
13+
14+
15+
class ServerOnboardingState(BaseModel):
16+
"""Mirrors the GET /api/v3/accounts/{id}/onboarding/state/ response.
17+
18+
Resource FK fields are added incrementally as each phase ships.
19+
"""
20+
21+
status: str
22+
data: dict[str, Any] = {}
23+
24+
25+
class OnboardingStateResult(BaseModel):
26+
phase: str | None
27+
session_id: str | None
28+
server_state: ServerOnboardingState | None
29+
30+
31+
def decision_points_to_dicts(points: list[DecisionPoint]) -> list[dict[str, Any]]:
32+
return [
33+
{
34+
"key": p.key,
35+
"label": p.label,
36+
"description": p.description,
37+
"sensitive": p.sensitive,
38+
"rationale_hint": p.rationale_hint,
39+
"examples": p.examples,
40+
}
41+
for p in points
42+
]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from dataclasses import dataclass, field
5+
from enum import Enum
6+
from typing import Any
7+
8+
9+
class SessionPhase(str, Enum):
10+
DECIDING = "deciding"
11+
REVIEWING = "reviewing"
12+
APPLYING = "applying"
13+
APPLY_FAILED = "apply_failed"
14+
AWAITING_VALIDATION = "awaiting_validation"
15+
VALIDATING = "validating"
16+
COMPLETE = "complete"
17+
VALIDATION_FAILED = "validation_failed"
18+
APPLIED_UNVALIDATED = "applied_unvalidated"
19+
20+
21+
# Phases where server-side state should be fetched alongside local session phase
22+
_PHASES_WITH_SERVER_STATE = {
23+
SessionPhase.APPLYING,
24+
SessionPhase.APPLY_FAILED,
25+
SessionPhase.AWAITING_VALIDATION,
26+
SessionPhase.VALIDATING,
27+
SessionPhase.COMPLETE,
28+
SessionPhase.VALIDATION_FAILED,
29+
SessionPhase.APPLIED_UNVALIDATED,
30+
}
31+
32+
33+
@dataclass
34+
class OnboardingSession:
35+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
36+
account_id: int = 0
37+
phase: SessionPhase = SessionPhase.DECIDING
38+
decisions: dict[str, Any] = field(default_factory=dict)
39+
40+
def has_server_state(self) -> bool:
41+
return self.phase in _PHASES_WITH_SERVER_STATE
42+
43+
44+
class InMemorySessionStore:
45+
"""In-memory session store keyed by account_id.
46+
47+
Per-process — survives the lifetime of the MCP server process but not restarts.
48+
Sufficient for stdio transport. Remote MCP persistence is a future concern.
49+
"""
50+
51+
def __init__(self) -> None:
52+
self._sessions: dict[int, OnboardingSession] = {}
53+
54+
def get_or_create(self, account_id: int) -> OnboardingSession:
55+
if account_id not in self._sessions:
56+
self._sessions[account_id] = OnboardingSession(account_id=account_id)
57+
return self._sessions[account_id]
58+
59+
def get(self, account_id: int) -> OnboardingSession | None:
60+
return self._sessions.get(account_id)
61+
62+
def update(self, session: OnboardingSession) -> None:
63+
self._sessions[session.account_id] = session
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from dataclasses import dataclass
2+
3+
from mcp.server.fastmcp import FastMCP
4+
5+
from dbt_mcp.config.config_providers import AdminApiConfig, ConfigProvider
6+
from dbt_mcp.dbt_admin.onboarding.client import OnboardingClient
7+
from dbt_mcp.dbt_admin.onboarding.decision_points import get_decision_points
8+
from dbt_mcp.dbt_admin.onboarding.models import (
9+
OnboardingInitResult,
10+
OnboardingStateResult,
11+
ServerOnboardingState,
12+
decision_points_to_dicts,
13+
)
14+
from dbt_mcp.dbt_admin.onboarding.session import InMemorySessionStore
15+
from dbt_mcp.prompts.prompts import get_prompt
16+
from dbt_mcp.tools.definitions import dbt_mcp_tool
17+
from dbt_mcp.tools.register import register_tools
18+
from dbt_mcp.tools.tool_names import ToolName
19+
from dbt_mcp.tools.toolsets import Toolset
20+
21+
22+
@dataclass
23+
class OnboardingToolContext:
24+
admin_api_config_provider: ConfigProvider[AdminApiConfig]
25+
session_store: InMemorySessionStore
26+
onboarding_client: OnboardingClient
27+
28+
29+
@dbt_mcp_tool(
30+
description=get_prompt("admin_api/onboarding_init"),
31+
title="Start or Resume Onboarding",
32+
read_only_hint=False,
33+
destructive_hint=False,
34+
idempotent_hint=True,
35+
)
36+
async def dbt_admin_onboarding_init(
37+
context: OnboardingToolContext,
38+
) -> OnboardingInitResult:
39+
"""Start or resume an onboarding session for the account."""
40+
config = await context.admin_api_config_provider.get_config()
41+
account_id = config.account_id
42+
43+
session = context.session_store.get_or_create(account_id)
44+
45+
return OnboardingInitResult(
46+
session_id=session.session_id,
47+
phase=session.phase.value,
48+
account_id=account_id,
49+
decision_points=decision_points_to_dicts(get_decision_points()),
50+
)
51+
52+
53+
@dbt_mcp_tool(
54+
description=get_prompt("admin_api/onboarding_state"),
55+
title="Get Onboarding State",
56+
read_only_hint=True,
57+
destructive_hint=False,
58+
idempotent_hint=True,
59+
)
60+
async def dbt_admin_onboarding_state(
61+
context: OnboardingToolContext,
62+
) -> OnboardingStateResult:
63+
"""Return the current onboarding session state for the account."""
64+
config = await context.admin_api_config_provider.get_config()
65+
account_id = config.account_id
66+
67+
session = context.session_store.get(account_id)
68+
69+
if session is None:
70+
return OnboardingStateResult(
71+
phase=None,
72+
session_id=None,
73+
server_state=None,
74+
)
75+
76+
server_state: ServerOnboardingState | None = None
77+
if session.has_server_state():
78+
raw = await context.onboarding_client.get_state(account_id)
79+
server_state = ServerOnboardingState(
80+
status=raw.get("data", {}).get("status", raw.get("status", "unknown")),
81+
data=raw.get("data", raw),
82+
)
83+
84+
return OnboardingStateResult(
85+
phase=session.phase.value,
86+
session_id=session.session_id,
87+
server_state=server_state,
88+
)
89+
90+
91+
ONBOARDING_TOOLS = [
92+
dbt_admin_onboarding_init,
93+
dbt_admin_onboarding_state,
94+
]
95+
96+
97+
def register_onboarding_tools(
98+
dbt_mcp: FastMCP,
99+
admin_api_config_provider: ConfigProvider[AdminApiConfig],
100+
*,
101+
disabled_tools: set[ToolName],
102+
enabled_tools: set[ToolName] | None,
103+
enabled_toolsets: set[Toolset],
104+
disabled_toolsets: set[Toolset],
105+
) -> None:
106+
"""Register dbt onboarding tools."""
107+
session_store = InMemorySessionStore()
108+
onboarding_client = OnboardingClient(admin_api_config_provider)
109+
110+
def bind_context() -> OnboardingToolContext:
111+
return OnboardingToolContext(
112+
admin_api_config_provider=admin_api_config_provider,
113+
session_store=session_store,
114+
onboarding_client=onboarding_client,
115+
)
116+
117+
register_tools(
118+
dbt_mcp,
119+
tool_definitions=[tool.adapt_context(bind_context) for tool in ONBOARDING_TOOLS],
120+
disabled_tools=disabled_tools,
121+
enabled_tools=enabled_tools,
122+
enabled_toolsets=enabled_toolsets,
123+
disabled_toolsets=disabled_toolsets,
124+
)

src/dbt_mcp/mcp/server.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from mcp.types import ContentBlock, Tool
1313

1414
from dbt_mcp.config.config import Config
15+
from dbt_mcp.dbt_admin.onboarding.tools import register_onboarding_tools
1516
from dbt_mcp.dbt_admin.tools import register_admin_api_tools
1617
from dbt_mcp.dbt_cli.tools import register_dbt_cli_tools
1718
from dbt_mcp.dbt_codegen.tools import register_dbt_codegen_tools
@@ -232,6 +233,15 @@ async def register_multi_project_dbt_mcp(dbt_mcp: FastMCP, config: Config) -> No
232233
enabled_toolsets=enabled_toolsets,
233234
disabled_toolsets=disabled_toolsets,
234235
)
236+
logger.info("Registering onboarding tools for multi-project")
237+
register_onboarding_tools(
238+
dbt_mcp,
239+
config.admin_api_config_provider,
240+
disabled_tools=disabled_tools,
241+
enabled_tools=enabled_tools,
242+
enabled_toolsets=enabled_toolsets,
243+
disabled_toolsets=disabled_toolsets,
244+
)
235245

236246
# Product docs and MCP server metadata are globally available (not project-specific)
237247
logger.info("Registering product docs tools for multi-project")
@@ -336,6 +346,16 @@ async def register_dbt_mcp_tools(dbt_mcp: FastMCP, config: Config) -> None:
336346
disabled_toolsets=disabled_toolsets,
337347
)
338348

349+
logger.info("Registering onboarding tools")
350+
register_onboarding_tools(
351+
dbt_mcp,
352+
config.admin_api_config_provider,
353+
disabled_tools=disabled_tools,
354+
enabled_tools=enabled_tools,
355+
enabled_toolsets=enabled_toolsets,
356+
disabled_toolsets=disabled_toolsets,
357+
)
358+
339359
if config.lsp_config:
340360
logger.info("Registering LSP tools")
341361
await register_lsp_tools(
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Start or resume an onboarding session for this dbt platform account.
2+
3+
Call this at the beginning of any onboarding conversation. It issues a `session_id` and returns the full decision tree — the set of values the user needs to confirm before the platform can be configured.
4+
5+
Returns:
6+
- **session_id**: A unique identifier for this onboarding session. Pass it to subsequent onboarding tools.
7+
- **phase**: The current session phase. Starts as `deciding` and progresses through `reviewing → applying → awaiting_validation → complete`.
8+
- **account_id**: The account being onboarded.
9+
- **decision_points**: The list of decisions the user must make, each with a `key`, `label`, `description`, optional `rationale_hint` for how to suggest a value, and `examples`. Empty in the Foundation phase; populated as account/project/environment/job phases ship.
10+
11+
If a session already exists for this account, it is resumed from the current phase — the user does not need to restart.
12+
13+
Suggest values for non-`sensitive` decision points based on detected project context. Never invent values for `sensitive: true` keys — ask the user explicitly.

0 commit comments

Comments
 (0)