Skip to content

Commit 0b8e8a8

Browse files
joaodaherclaude
andcommitted
CC-3677: account-create tool + owner-token stash
Phase 0 (MCP side) of agent-driven onboarding. Adds the low-level `dbt_admin_account_create(name, owner_email, created_via?)` tool that calls the public dbt-cloud POST /api/v3/accounts/ endpoint via a token-less, host-only client (account creation happens before any account or token exists, so it must not trigger the OAuth login flow). On success it stashes the returned account_id + owner token into the shared CredentialsProvider, so the existing account-scoped admin/onboarding tools authenticate automatically for the rest of the session. Gated under the admin_api toolset; billable so destructive-confirm applies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2db612c commit 0b8e8a8

11 files changed

Lines changed: 291 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ 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_account_create`: Bootstraps a brand-new trial account and owner token (billable); the token is stashed for the rest of the session.
8586
- `dbt_admin_onboarding_apply`: Submits collected onboarding data to the platform; call incrementally as each piece of data is gathered.
8687
- `dbt_admin_onboarding_get`: Returns the current onboarding record and progress; null if no onboarding has been started.
8788
- `dbt_admin_onboarding_validate`: Validates the collected onboarding data and returns what is missing or invalid.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import TYPE_CHECKING, Any
5+
6+
import httpx
7+
8+
from dbt_mcp.errors import AdminAPIError, InvalidParameterError
9+
10+
if TYPE_CHECKING:
11+
from dbt_mcp.config.credentials import CredentialsProvider
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class AccountClient:
17+
"""HTTP client for the public, unauthenticated account-create endpoint.
18+
19+
Account creation happens *before* any account or token exists, so this
20+
client only needs the host — it deliberately does NOT call
21+
``credentials_provider.get_credentials()`` (which would trigger the OAuth
22+
login flow). It reads the host straight off the configured settings.
23+
"""
24+
25+
def __init__(self, credentials_provider: CredentialsProvider) -> None:
26+
self.credentials_provider = credentials_provider
27+
28+
def _url(self) -> str:
29+
settings = self.credentials_provider.settings
30+
host = settings.actual_host
31+
if not host:
32+
raise InvalidParameterError(
33+
"DBT_HOST is required to create an account but is not configured."
34+
)
35+
if settings.actual_host_prefix:
36+
base = f"https://{settings.actual_host_prefix}.{settings.base_host}"
37+
else:
38+
base = f"https://{host}"
39+
return f"{base}/api/v3/accounts/"
40+
41+
async def create(
42+
self,
43+
*,
44+
name: str,
45+
owner_email: str,
46+
created_via: str | None = None,
47+
) -> dict[str, Any]:
48+
"""Create a trial account + owner token. Returns the response ``data`` dict."""
49+
url = self._url()
50+
body: dict[str, Any] = {"name": name, "owner_email": owner_email}
51+
if created_via is not None:
52+
body["created_via"] = created_via
53+
54+
try:
55+
async with httpx.AsyncClient() as client:
56+
response = await client.post(
57+
url,
58+
headers={
59+
"Content-Type": "application/json",
60+
"Accept": "application/json",
61+
},
62+
json=body,
63+
follow_redirects=True,
64+
)
65+
response.raise_for_status()
66+
data = response.json().get("data")
67+
if not data:
68+
raise AdminAPIError("Account create returned no data")
69+
return data
70+
except httpx.HTTPStatusError as e:
71+
if 400 <= e.response.status_code < 500:
72+
raise InvalidParameterError(
73+
f"Account create failed ({e.response.status_code})"
74+
) from e
75+
raise AdminAPIError(
76+
f"Account create failed ({e.response.status_code})"
77+
) from e
78+
except httpx.HTTPError as e:
79+
raise AdminAPIError("Account create failed") from e

src/dbt_mcp/dbt_admin/onboarding/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ def from_api(cls, data: dict[str, Any]) -> "OnboardingModel":
3535
)
3636

3737

38+
class AccountCreateResult(BaseModel):
39+
"""Result of bootstrapping a brand-new account via the public endpoint."""
40+
41+
account_id: int
42+
owner_token: str
43+
44+
3845
class OnboardingGetResult(BaseModel):
3946
onboarding: OnboardingModel | None
4047

src/dbt_mcp/dbt_admin/onboarding/tools.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,69 @@
11
from dataclasses import dataclass
2-
from typing import Any
2+
from typing import TYPE_CHECKING, Any
33

44
from mcp.server.fastmcp import FastMCP
55

66
from dbt_mcp.config.config_providers import AdminApiConfig, ConfigProvider
7+
from dbt_mcp.dbt_admin.onboarding.account_client import AccountClient
78
from dbt_mcp.dbt_admin.onboarding.client import OnboardingClient
89
from dbt_mcp.dbt_admin.onboarding.models import (
10+
AccountCreateResult,
911
OnboardingApplyResult,
1012
OnboardingGetResult,
1113
OnboardingModel,
1214
OnboardingValidateResult,
1315
)
16+
from dbt_mcp.oauth.token_provider import StaticTokenProvider
1417
from dbt_mcp.prompts.prompts import get_prompt
1518
from dbt_mcp.tools.definitions import dbt_mcp_tool
1619
from dbt_mcp.tools.register import register_tools
1720
from dbt_mcp.tools.tool_names import ToolName
1821
from dbt_mcp.tools.toolsets import Toolset
1922

23+
if TYPE_CHECKING:
24+
from dbt_mcp.config.credentials import CredentialsProvider
25+
2026

2127
@dataclass
2228
class OnboardingToolContext:
2329
admin_api_config_provider: ConfigProvider[AdminApiConfig]
2430
onboarding_client: OnboardingClient
31+
credentials_provider: "CredentialsProvider"
32+
account_client: AccountClient
33+
34+
35+
@dbt_mcp_tool(
36+
description=get_prompt("admin_api/account_create"),
37+
title="Create dbt Platform Account",
38+
read_only_hint=False,
39+
destructive_hint=False,
40+
idempotent_hint=False,
41+
)
42+
async def dbt_admin_account_create(
43+
context: OnboardingToolContext,
44+
name: str,
45+
owner_email: str,
46+
created_via: str | None = None,
47+
) -> AccountCreateResult:
48+
"""Bootstrap a brand-new dbt platform account and owner token.
49+
50+
Use this only when the user has no account yet — it is billable and creates
51+
a trial account. The returned owner token is stashed for the rest of this
52+
session, so subsequent admin/onboarding tools authenticate automatically.
53+
"""
54+
data = await context.account_client.create(
55+
name=name,
56+
owner_email=owner_email,
57+
created_via=created_via,
58+
)
59+
account_id = int(data["account_id"])
60+
owner_token = data["owner_token"]
61+
62+
# Stash the new identity so later account-scoped tools authenticate.
63+
context.credentials_provider.settings.dbt_account_id = account_id
64+
context.credentials_provider.token_provider = StaticTokenProvider(token=owner_token)
65+
66+
return AccountCreateResult(account_id=account_id, owner_token=owner_token)
2567

2668

2769
@dbt_mcp_tool(
@@ -82,6 +124,7 @@ async def dbt_admin_onboarding_apply(
82124

83125

84126
ONBOARDING_TOOLS = [
127+
dbt_admin_account_create,
85128
dbt_admin_onboarding_get,
86129
dbt_admin_onboarding_validate,
87130
dbt_admin_onboarding_apply,
@@ -91,6 +134,7 @@ async def dbt_admin_onboarding_apply(
91134
def register_onboarding_tools(
92135
dbt_mcp: FastMCP,
93136
admin_api_config_provider: ConfigProvider[AdminApiConfig],
137+
credentials_provider: "CredentialsProvider",
94138
*,
95139
disabled_tools: set[ToolName],
96140
enabled_tools: set[ToolName] | None,
@@ -99,11 +143,14 @@ def register_onboarding_tools(
99143
) -> None:
100144
"""Register dbt onboarding tools."""
101145
onboarding_client = OnboardingClient(admin_api_config_provider)
146+
account_client = AccountClient(credentials_provider)
102147

103148
def bind_context() -> OnboardingToolContext:
104149
return OnboardingToolContext(
105150
admin_api_config_provider=admin_api_config_provider,
106151
onboarding_client=onboarding_client,
152+
credentials_provider=credentials_provider,
153+
account_client=account_client,
107154
)
108155

109156
register_tools(

src/dbt_mcp/mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ async def register_multi_project_dbt_mcp(dbt_mcp: FastMCP, config: Config) -> No
237237
register_onboarding_tools(
238238
dbt_mcp,
239239
config.admin_api_config_provider,
240+
config.credentials_provider.inner_provider,
240241
disabled_tools=disabled_tools,
241242
enabled_tools=enabled_tools,
242243
enabled_toolsets=enabled_toolsets,
@@ -350,6 +351,7 @@ async def register_dbt_mcp_tools(dbt_mcp: FastMCP, config: Config) -> None:
350351
register_onboarding_tools(
351352
dbt_mcp,
352353
config.admin_api_config_provider,
354+
config.credentials_provider.inner_provider,
353355
disabled_tools=disabled_tools,
354356
enabled_tools=enabled_tools,
355357
enabled_toolsets=enabled_toolsets,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Creates a brand-new dbt platform account and returns an owner API token.
2+
3+
Use this only when the user does not have an account yet. It is billable — it spins up a trial account — so confirm with the user before calling. If the user already has an account, do not call this; use their existing account instead.
4+
5+
Provide the desired account `name` and the owner's `owner_email`. Optionally pass `created_via` (e.g. `onboarding_api`) for funnel attribution; leave it unset if unsure.
6+
7+
The returned owner token is stored for the rest of this session, so the account-scoped admin and onboarding tools will authenticate automatically afterwards — you do not need to ask the user for a token.

src/dbt_mcp/tools/readme_mappings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
ToolName.LIST_JOB_RUN_ARTIFACTS: "Lists available artifacts from a job run.",
6161
ToolName.GET_JOB_RUN_ERROR: "Gets error and/or warning details for a job run; option to include or show warnings only.",
6262
# Onboarding tools
63+
ToolName.ACCOUNT_CREATE: "Bootstraps a brand-new trial account and owner token (billable); the token is stashed for the rest of the session.",
6364
ToolName.ONBOARDING_GET: "Returns the current onboarding record and progress; null if no onboarding has been started.",
6465
ToolName.ONBOARDING_VALIDATE: "Validates the collected onboarding data and returns what is missing or invalid.",
6566
ToolName.ONBOARDING_APPLY: "Submits collected onboarding data to the platform; call incrementally as each piece of data is gathered.",

src/dbt_mcp/tools/tool_names.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class ToolName(Enum):
6565
GET_JOB_RUN_ERROR = "get_job_run_error"
6666

6767
# Onboarding tools
68+
ACCOUNT_CREATE = "dbt_admin_account_create"
6869
ONBOARDING_GET = "dbt_admin_onboarding_get"
6970
ONBOARDING_VALIDATE = "dbt_admin_onboarding_validate"
7071
ONBOARDING_APPLY = "dbt_admin_onboarding_apply"

src/dbt_mcp/tools/toolsets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class Toolset(Enum):
103103
ToolName.RETRY_JOB_RUN,
104104
ToolName.LIST_JOB_RUN_ARTIFACTS,
105105
ToolName.GET_JOB_RUN_ERROR,
106+
ToolName.ACCOUNT_CREATE,
106107
ToolName.ONBOARDING_GET,
107108
ToolName.ONBOARDING_VALIDATE,
108109
ToolName.ONBOARDING_APPLY,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
import pytest
5+
6+
from dbt_mcp.dbt_admin.onboarding.account_client import AccountClient
7+
from dbt_mcp.errors import InvalidParameterError
8+
9+
10+
def _creds(actual_host, actual_host_prefix=None, base_host=None):
11+
return SimpleNamespace(
12+
settings=SimpleNamespace(
13+
actual_host=actual_host,
14+
actual_host_prefix=actual_host_prefix,
15+
base_host=base_host or actual_host,
16+
)
17+
)
18+
19+
20+
def _mock_httpx_client(mock_response):
21+
mock_client = AsyncMock()
22+
mock_client.post = AsyncMock(return_value=mock_response)
23+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
24+
mock_client.__aexit__ = AsyncMock(return_value=None)
25+
return mock_client
26+
27+
28+
async def test_create_posts_unauthenticated_to_accounts_url():
29+
client = AccountClient(_creds("cloud.getdbt.com"))
30+
mock_response = MagicMock()
31+
mock_response.json.return_value = {"data": {"account_id": 7, "owner_token": "tok"}}
32+
mock_response.raise_for_status.return_value = None
33+
mock_client = _mock_httpx_client(mock_response)
34+
35+
with patch("httpx.AsyncClient", return_value=mock_client):
36+
data = await client.create(name="acme", owner_email="o@acme.example")
37+
38+
assert data == {"account_id": 7, "owner_token": "tok"}
39+
args, kwargs = mock_client.post.call_args
40+
assert args[0] == "https://cloud.getdbt.com/api/v3/accounts/"
41+
assert kwargs["json"] == {"name": "acme", "owner_email": "o@acme.example"}
42+
# Public endpoint — no Authorization header is sent.
43+
assert "Authorization" not in kwargs["headers"]
44+
45+
46+
async def test_create_includes_created_via_when_provided():
47+
client = AccountClient(_creds("cloud.getdbt.com"))
48+
mock_response = MagicMock()
49+
mock_response.json.return_value = {"data": {"account_id": 7, "owner_token": "tok"}}
50+
mock_response.raise_for_status.return_value = None
51+
mock_client = _mock_httpx_client(mock_response)
52+
53+
with patch("httpx.AsyncClient", return_value=mock_client):
54+
await client.create(
55+
name="acme", owner_email="o@acme.example", created_via="onboarding_api"
56+
)
57+
58+
assert mock_client.post.call_args.kwargs["json"]["created_via"] == "onboarding_api"
59+
60+
61+
async def test_create_uses_host_prefix():
62+
client = AccountClient(_creds("cloud.getdbt.com", actual_host_prefix="ab123"))
63+
mock_response = MagicMock()
64+
mock_response.json.return_value = {"data": {"account_id": 1, "owner_token": "t"}}
65+
mock_response.raise_for_status.return_value = None
66+
mock_client = _mock_httpx_client(mock_response)
67+
68+
with patch("httpx.AsyncClient", return_value=mock_client):
69+
await client.create(name="a", owner_email="o@a.example")
70+
71+
assert (
72+
mock_client.post.call_args.args[0]
73+
== "https://ab123.cloud.getdbt.com/api/v3/accounts/"
74+
)
75+
76+
77+
async def test_create_without_host_raises():
78+
client = AccountClient(_creds(None))
79+
with pytest.raises(InvalidParameterError):
80+
await client.create(name="a", owner_email="o@a.example")

0 commit comments

Comments
 (0)