Skip to content

feat: extract project/environment helpers for multi-project support#657

Open
DevonFulcher wants to merge 4 commits intodf/migrate-requests-to-httpxfrom
df/multiproject-phase1-core
Open

feat: extract project/environment helpers for multi-project support#657
DevonFulcher wants to merge 4 commits intodf/migrate-requests-to-httpxfrom
df/multiproject-phase1-core

Conversation

@DevonFulcher
Copy link
Collaborator

@DevonFulcher DevonFulcher commented Mar 17, 2026

Why

This is Phase 1 of the multi-project feature, extracting shared helper functions that both Server A (env-var mode) and Server B (multi-project mode) will use. This extraction is the blocking dependency for all subsequent phases.

What

  • New src/dbt_mcp/project/ module with extracted helpers:

    • environment_resolver.pyresolve_environments() (pure logic) and get_environments_for_project() (API + resolution)
    • project_resolver.pyget_all_accounts() and get_all_projects_for_account()
  • Refactored config providers (config_providers.py):

    • Each provider now has only get_config() (existing, unchanged behavior) and a _build_config() static method
    • Removed get_config_for_project(project_id) from all four providers — env resolution belongs in tool functions, not providers, keeping providers stateless
    • Tools in phases 3-6 will call get_environments_for_project() directly at call-time and pass the resolved environment ID to the provider's _build_config() or construct the config override inline
  • Refactored fastapi_app.py:

    • Replaced inline account/project/environment functions with calls to the new extracted helpers
    • Replaced inline environment resolution logic with resolve_environments()
    • No behavior change
  • Added DBT_MCP_MULTI_PROJECT_ENABLED env var check in main.py:

    • When set to true/1/yes, raises NotImplementedError (stub for Server B, implemented in Phase 7)
    • When absent/false, existing behavior unchanged (Server A)
  • Updated existing pagination tests to reference new module paths

Notes

  • All 451 unit tests pass
  • task check passes (lint, format, mypy)
  • No behavior change for existing Server A mode

Drafted by Claude Sonnet 4.6 under the direction of @DevonFulcher

Comment on lines +26 to +29
response = await client.get(
f"{dbt_platform_url}/api/v3/accounts/{account_id}/projects/{project_id}/environments/?state=1&offset={offset}&limit={page_size}",
headers=headers,
)

Check failure

Code scanning / CodeQL

Partial server-side request forgery

Part of the URL of this request depends on a [user-provided value](1). Part of the URL of this request depends on a [user-provided value](2).

Copilot Autofix

AI about 10 hours ago

In general, to fix partial SSRF in this pattern you constrain user-controlled path components to a safe, expected subset and/or verify that they refer to resources the caller is actually allowed to access. For numeric IDs, that means enforcing integer types and checking that the requested IDs are present in the set of accounts/projects associated with the authenticated user, instead of blindly passing any ID into a backend HTTP call.

Concretely here, the vulnerable pieces are the account_id/project_id values passed into _get_all_environments_for_project from get_deployment_environments and set_selected_project. The best fix without changing intended functionality is to validate that the requested account_id corresponds to one of the accounts returned by get_all_accounts for the current token, and that the project_id corresponds to one of the projects for that account from get_all_projects_for_account. If either does not exist, we return a 4xx-style error (by raising a ValueError as is already done for a missing account in set_selected_project). This both satisfies CodeQL (the URL path still uses user-supplied IDs but now only after authorization/validation) and enforces proper access control.

Specifically:

  • In get_deployment_environments (/environments handler):
    • After building headers, call get_all_accounts(dbt_platform_url, headers) and ensure request.account_id is present. If not, raise ValueError.
    • Then call get_all_projects_for_account for that account and ensure request.project_id exists. If not, raise ValueError.
    • Only then call _get_all_environments_for_project with those IDs.
  • In set_selected_project (/selected_project handler):
    • It already validates the account by ID. Add a similar validation step for selected_project_request.project_id by calling get_all_projects_for_account with the resolved account and verifying the project exists before calling _get_all_environments_for_project.

All of these changes occur in src/dbt_mcp/oauth/fastapi_app.py. We do not need to modify _get_all_environments_for_project in environment_resolver.py; it continues to accept IDs but will only be called with IDs that have been vetted against the user’s accessible resources. No new imports are required, as we already import get_all_accounts and get_all_projects_for_account.


Suggested changeset 1
src/dbt_mcp/oauth/fastapi_app.py
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/dbt_mcp/oauth/fastapi_app.py b/src/dbt_mcp/oauth/fastapi_app.py
--- a/src/dbt_mcp/oauth/fastapi_app.py
+++ b/src/dbt_mcp/oauth/fastapi_app.py
@@ -173,10 +173,26 @@
             "Accept": "application/json",
             "Authorization": f"Bearer {access_token}",
         }
+        # Validate that the requested account and project belong to the authenticated user
+        accounts = await get_all_accounts(
+            dbt_platform_url=dbt_platform_url,
+            headers=headers,
+        )
+        account = next((a for a in accounts if a.id == request.account_id), None)
+        if account is None:
+            raise ValueError(f"Account {request.account_id} not found")
+        projects = await get_all_projects_for_account(
+            dbt_platform_url=dbt_platform_url,
+            account=account,
+            headers=headers,
+        )
+        project = next((p for p in projects if p.id == request.project_id), None)
+        if project is None:
+            raise ValueError(f"Project {request.project_id} not found for account {request.account_id}")
         environments = await _get_all_environments_for_project(
             dbt_platform_url=dbt_platform_url,
-            account_id=request.account_id,
-            project_id=request.project_id,
+            account_id=account.id,
+            project_id=project.id,
             headers=headers,
             page_size=100,
         )
@@ -208,10 +222,23 @@
         )
         if account is None:
             raise ValueError(f"Account {selected_project_request.account_id} not found")
+        # Validate that the selected project belongs to the selected account
+        projects = await get_all_projects_for_account(
+            dbt_platform_url=dbt_platform_url,
+            account=account,
+            headers=headers,
+        )
+        project = next(
+            (p for p in projects if p.id == selected_project_request.project_id), None
+        )
+        if project is None:
+            raise ValueError(
+                f"Project {selected_project_request.project_id} not found for account {selected_project_request.account_id}"
+            )
         environments = await _get_all_environments_for_project(
             dbt_platform_url=dbt_platform_url,
-            account_id=selected_project_request.account_id,
-            project_id=selected_project_request.project_id,
+            account_id=account.id,
+            project_id=project.id,
             headers=headers,
             page_size=100,
         )
EOF
@@ -173,10 +173,26 @@
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
}
# Validate that the requested account and project belong to the authenticated user
accounts = await get_all_accounts(
dbt_platform_url=dbt_platform_url,
headers=headers,
)
account = next((a for a in accounts if a.id == request.account_id), None)
if account is None:
raise ValueError(f"Account {request.account_id} not found")
projects = await get_all_projects_for_account(
dbt_platform_url=dbt_platform_url,
account=account,
headers=headers,
)
project = next((p for p in projects if p.id == request.project_id), None)
if project is None:
raise ValueError(f"Project {request.project_id} not found for account {request.account_id}")
environments = await _get_all_environments_for_project(
dbt_platform_url=dbt_platform_url,
account_id=request.account_id,
project_id=request.project_id,
account_id=account.id,
project_id=project.id,
headers=headers,
page_size=100,
)
@@ -208,10 +222,23 @@
)
if account is None:
raise ValueError(f"Account {selected_project_request.account_id} not found")
# Validate that the selected project belongs to the selected account
projects = await get_all_projects_for_account(
dbt_platform_url=dbt_platform_url,
account=account,
headers=headers,
)
project = next(
(p for p in projects if p.id == selected_project_request.project_id), None
)
if project is None:
raise ValueError(
f"Project {selected_project_request.project_id} not found for account {selected_project_request.account_id}"
)
environments = await _get_all_environments_for_project(
dbt_platform_url=dbt_platform_url,
account_id=selected_project_request.account_id,
project_id=selected_project_request.project_id,
account_id=account.id,
project_id=project.id,
headers=headers,
page_size=100,
)
Copilot is powered by AI and may make mistakes. Always verify output.
@DevonFulcher DevonFulcher changed the base branch from main to df/migrate-requests-to-httpx March 17, 2026 17:44
@DevonFulcher DevonFulcher force-pushed the df/multiproject-phase1-core branch from 69ce9db to 9afd83f Compare March 17, 2026 17:49
@DevonFulcher DevonFulcher force-pushed the df/migrate-requests-to-httpx branch from beeaff9 to 4cf34e6 Compare March 17, 2026 17:52
DevonFulcher and others added 4 commits March 17, 2026 12:53
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This will be introduced in the first phase where it has non-empty content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DevonFulcher DevonFulcher force-pushed the df/multiproject-phase1-core branch from 3a4bf94 to 70b2a82 Compare March 17, 2026 17:54
@DevonFulcher DevonFulcher marked this pull request as ready for review March 17, 2026 18:15
@DevonFulcher DevonFulcher requested review from a team, b-per, jairus-m and jasnonaz as code owners March 17, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant