Skip to content

Commit 013c2e2

Browse files
committed
Add version awareness to product_docs toolset
Detect the user's dbt version from dbt_project.yml require-dbt-version at server startup and surface it in search/page responses so the LLM can give version-contextualized guidance. EOL pages (v1.6 and older) are annotated with a warning, and both tool prompts now include VERSION AWARENESS instructions for the LLM. Made-with: Cursor
1 parent 68ba27e commit 013c2e2

File tree

10 files changed

+210
-4
lines changed

10 files changed

+210
-4
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: Enhancement or New Feature
2+
body: 'Add version awareness to product_docs toolset: detects dbt version from dbt_project.yml,
3+
includes it in search/page responses, annotates EOL pages, and adds LLM prompt instructions
4+
for version-specific guidance'
5+
time: 2026-03-11T10:31:00.789229Z

src/dbt_mcp/config/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
DefaultProxiedToolConfigProvider,
88
DefaultSemanticLayerConfigProvider,
99
)
10+
from dbt_mcp.config.dbt_project import parse_dbt_version_minor
1011
from dbt_mcp.config.settings import (
1112
CredentialsProvider,
1213
DbtMcpSettings,
@@ -81,6 +82,7 @@ class Config:
8182
admin_api_config_provider: DefaultAdminApiConfigProvider | None
8283
credentials_provider: CredentialsProvider
8384
lsp_config: LspConfig | None
85+
dbt_version: str | None = None
8486

8587

8688
def load_config(enable_proxied_tools: bool = True) -> Config:
@@ -161,6 +163,11 @@ def load_config(enable_proxied_tools: bool = True) -> Config:
161163
lsp_binary_info=lsp_binary_info,
162164
)
163165

166+
dbt_version: str | None = None
167+
project_yml = settings.dbt_project_yml
168+
if project_yml and project_yml.require_dbt_version:
169+
dbt_version = parse_dbt_version_minor(project_yml.require_dbt_version)
170+
164171
return Config(
165172
disable_tools=settings.disable_tools or [],
166173
enable_tools=settings.enable_tools,
@@ -174,4 +181,5 @@ def load_config(enable_proxied_tools: bool = True) -> Config:
174181
admin_api_config_provider=admin_api_config_provider,
175182
credentials_provider=credentials_provider,
176183
lsp_config=lsp_config,
184+
dbt_version=dbt_version,
177185
)

src/dbt_mcp/config/dbt_project.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from pydantic import BaseModel, ConfigDict
1+
import re
2+
3+
from pydantic import BaseModel, ConfigDict, Field
24

35

46
class DbtProjectFlags(BaseModel):
@@ -9,3 +11,22 @@ class DbtProjectFlags(BaseModel):
911
class DbtProjectYaml(BaseModel):
1012
model_config = ConfigDict(extra="allow")
1113
flags: None | DbtProjectFlags = None
14+
require_dbt_version: str | list[str] | None = Field(
15+
None, alias="require-dbt-version"
16+
)
17+
18+
19+
_DBT_MINOR_VERSION_RE = re.compile(r"1\.(\d+)")
20+
21+
22+
def parse_dbt_version_minor(require_dbt_version: str | list[str] | None) -> str | None:
23+
"""Extract the minimum dbt minor version (e.g. ``"1.8"``) from a ``require-dbt-version`` spec.
24+
25+
Handles common forms: ``">=1.8.0"``, ``"1.8"``, ``[">=1.8.0", "<2.0"]``.
26+
Returns ``None`` when the version cannot be determined.
27+
"""
28+
if require_dbt_version is None:
29+
return None
30+
raw = require_dbt_version if isinstance(require_dbt_version, str) else require_dbt_version[0]
31+
match = _DBT_MINOR_VERSION_RE.search(raw)
32+
return f"1.{match.group(1)}" if match else None

src/dbt_mcp/mcp/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ async def create_dbt_mcp(config: Config) -> DbtMCP:
171171
logger.info("Registering product docs tools")
172172
register_product_docs_tools(
173173
dbt_mcp,
174+
dbt_version=config.dbt_version,
174175
disabled_tools=disabled_tools,
175176
enabled_tools=enabled_tools,
176177
enabled_toolsets=enabled_toolsets,

src/dbt_mcp/product_docs/client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@
8686

8787
_LLMS_TXT_ENTRY_RE = re.compile(r"^-\s+\[([^\]]+)\]\(([^)]+)\)(?::\s*(.+))?$")
8888

89+
_EOL_URL_RE = re.compile(r"/core-upgrade/Older(?:\s|%20)versions/", re.IGNORECASE)
90+
91+
EOL_PAGE_WARNING = (
92+
">>> VERSION NOTICE: This page describes a dbt Core version that has "
93+
"reached end-of-life (EOL) and is no longer supported. The information "
94+
"may be outdated or differ from current behavior. See the latest version "
95+
"support info at https://docs.getdbt.com/docs/dbt-versions/core\n\n---\n\n"
96+
)
97+
98+
99+
def detect_eol_page(url: str) -> bool:
100+
"""Return ``True`` if *url* points to a page for an EOL dbt Core version."""
101+
return bool(_EOL_URL_RE.search(url))
102+
89103
# Relevance scoring weights for search_index ranking.
90104
# Higher weights push results toward the top when terms appear in
91105
# more-specific fields (title > description > section).

src/dbt_mcp/product_docs/tools.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
from pydantic import Field
1818

1919
from dbt_mcp.product_docs.client import (
20+
EOL_PAGE_WARNING,
2021
MAX_CONTENT_CHARS_PER_PAGE,
2122
ProductDocsClient,
23+
detect_eol_page,
2224
display_url,
2325
expand_keywords,
2426
normalize_doc_url,
@@ -44,6 +46,7 @@
4446
@dataclass
4547
class ProductDocsToolContext:
4648
client: ProductDocsClient = field(default_factory=ProductDocsClient)
49+
dbt_version: str | None = None
4750

4851

4952
def _dict_to_doc_search_result(entry: dict[str, str]) -> DocSearchResult:
@@ -76,10 +79,17 @@ async def _fetch_page(client: ProductDocsClient, url: str) -> ProductDocPageResp
7679
error=f"Failed to fetch page: {normalized} ({e})",
7780
)
7881

82+
version_note: str | None = None
83+
if detect_eol_page(normalized):
84+
version_note = "This page is for an end-of-life dbt Core version (v1.6 or older)."
85+
content = EOL_PAGE_WARNING + content
86+
7987
content = truncate_content(
8088
content, MAX_CONTENT_CHARS_PER_PAGE, display_url(normalized)
8189
)
82-
return ProductDocPageResponse(url=display_url(normalized), content=content)
90+
return ProductDocPageResponse(
91+
url=display_url(normalized), content=content, version_note=version_note
92+
)
8393

8494

8595
@dbt_mcp_tool(
@@ -106,6 +116,7 @@ async def search_product_docs(
106116
total_matches=0,
107117
showing=0,
108118
results=[],
119+
dbt_project_version=context.dbt_version,
109120
error="Query must not be empty.",
110121
)
111122

@@ -136,6 +147,7 @@ async def search_product_docs(
136147
total_matches=len(doc_results),
137148
showing=len(doc_results),
138149
results=doc_results,
150+
dbt_project_version=context.dbt_version,
139151
search_method="query_expansion" if used_query_expansion else None,
140152
)
141153

@@ -184,7 +196,9 @@ async def get_product_doc_pages(
184196
else:
185197
pages.append(result)
186198

187-
return GetProductDocPagesResponse(pages=pages)
199+
return GetProductDocPagesResponse(
200+
pages=pages, dbt_project_version=context.dbt_version
201+
)
188202

189203

190204
PRODUCT_DOCS_TOOLS = [
@@ -196,6 +210,7 @@ async def get_product_doc_pages(
196210
def register_product_docs_tools(
197211
dbt_mcp: FastMCP,
198212
*,
213+
dbt_version: str | None = None,
199214
disabled_tools: set[ToolName],
200215
enabled_tools: set[ToolName] | None,
201216
enabled_toolsets: set[Toolset],
@@ -205,7 +220,7 @@ def register_product_docs_tools(
205220
shared_client = ProductDocsClient()
206221

207222
def bind_context() -> ProductDocsToolContext:
208-
return ProductDocsToolContext(client=shared_client)
223+
return ProductDocsToolContext(client=shared_client, dbt_version=dbt_version)
209224

210225
register_tools(
211226
dbt_mcp,

src/dbt_mcp/product_docs/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class SearchProductDocsResponse:
1515
total_matches: int
1616
showing: int
1717
results: list[DocSearchResult]
18+
dbt_project_version: str | None = None
1819
search_method: str | None = None
1920
error: str | None = None
2021

@@ -24,8 +25,10 @@ class ProductDocPageResponse:
2425
url: str
2526
content: str
2627
error: str | None = None
28+
version_note: str | None = None
2729

2830

2931
@dataclass
3032
class GetProductDocPagesResponse:
3133
pages: list[ProductDocPageResponse] = field(default_factory=list)
34+
dbt_project_version: str | None = None

src/dbt_mcp/prompts/product_docs/get_product_doc_pages.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,12 @@ IMPORTANT — how to present docs content to the user:
66
3. Call out practical limitations and edge cases from the docs.
77
4. Close with clear guidance: if the feature only partially answers the question, say what else they can do and where.
88
5. ALWAYS include the docs page URL(s) as markdown hyperlinks at the end of your response, e.g. [Page title](https://docs.getdbt.com/...).
9+
10+
VERSION AWARENESS:
11+
The response includes a `dbt_project_version` field with the user's dbt version from their dbt_project.yml. Pages may also have a `version_note` field if they cover an EOL version. If `dbt_project_version` is null, the version could not be detected — present docs normally without version-specific callouts.
12+
13+
When presenting page content to the user, you MUST:
14+
1. If `dbt_project_version` is set, tell the user their version and note any version mismatches with the content.
15+
2. If a page covers features from a newer dbt version, explicitly tell the user: "This feature is available in dbt [version]+. Your project currently uses [their version]. Upgrading would give you access to this."
16+
3. If `version_note` is set, prominently warn the user that the page is for an end-of-life version and the information may be outdated. Suggest they look at the current docs instead.
17+
4. Never silently present content from a different version than the user's without calling it out.

src/dbt_mcp/prompts/product_docs/search_product_docs.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,12 @@ Search the dbt product documentation at docs.getdbt.com for pages matching a que
33
If the title/description search finds few matches, it automatically falls back to a deep full-text search across all documentation content to find pages where the topic is discussed in the body text.
44

55
If your first query returns few results, try rephrasing with synonyms or the full term (e.g. 'user-defined functions' instead of 'UDFs', 'version control' instead of 'git'). Use the abbreviations on the page as well.
6+
7+
VERSION AWARENESS:
8+
The response includes a `dbt_project_version` field (e.g. "1.8") detected from the user's dbt_project.yml `require-dbt-version`. If null, the version could not be detected — present docs normally without version-specific callouts.
9+
10+
When presenting results to the user, you MUST:
11+
1. State the user's detected dbt version upfront if available (e.g. "Based on your project (dbt 1.8), here's what I found:").
12+
2. If a result is about a feature introduced in a newer version than the user's, say so explicitly (e.g. "This feature requires dbt 1.9+. You're currently on 1.8 — upgrading would give you access to this.").
13+
3. If a result is for an older/EOL version (v1.6 or earlier), clearly flag it: "Note: this page covers dbt v1.X, which has reached end-of-life and is no longer supported."
14+
4. Results include content for ALL dbt versions. The docs site does not serve version-specific pages, so always tell the user which version(s) a page applies to when it matters.

tests/unit/tools/test_product_docs.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
import pytest
77

88
from dbt_mcp.config.config import load_config
9+
from dbt_mcp.config.dbt_project import parse_dbt_version_minor
910
from dbt_mcp.dbt_cli.binary_type import BinaryType
1011
from dbt_mcp.mcp.server import create_dbt_mcp
1112
from dbt_mcp.product_docs.client import (
13+
detect_eol_page,
1214
normalize_doc_url,
1315
parse_llms_full_txt,
1416
parse_llms_txt,
@@ -93,6 +95,16 @@ def context(mock_client):
9395
"""Create ProductDocsToolContext with a mocked client."""
9496
ctx = ProductDocsToolContext.__new__(ProductDocsToolContext)
9597
ctx.client = mock_client
98+
ctx.dbt_version = None
99+
return ctx
100+
101+
102+
@pytest.fixture
103+
def versioned_context(mock_client):
104+
"""Create ProductDocsToolContext with a mocked client and dbt version set."""
105+
ctx = ProductDocsToolContext.__new__(ProductDocsToolContext)
106+
ctx.client = mock_client
107+
ctx.dbt_version = "1.8"
96108
return ctx
97109

98110

@@ -500,6 +512,115 @@ async def test_ranks_by_keyword_frequency(self):
500512
assert results[0]["url"].endswith("incremental-models-overview")
501513

502514

515+
class TestParseDbtVersionMinor:
516+
def test_string_constraint(self):
517+
assert parse_dbt_version_minor(">=1.8.0") == "1.8"
518+
519+
def test_list_constraint(self):
520+
assert parse_dbt_version_minor([">=1.8.0", "<2.0"]) == "1.8"
521+
522+
def test_bare_version(self):
523+
assert parse_dbt_version_minor("1.10") == "1.10"
524+
525+
def test_none(self):
526+
assert parse_dbt_version_minor(None) is None
527+
528+
def test_unparseable(self):
529+
assert parse_dbt_version_minor("latest") is None
530+
531+
def test_patch_version(self):
532+
assert parse_dbt_version_minor(">=1.9.3") == "1.9"
533+
534+
def test_exact_version_string(self):
535+
assert parse_dbt_version_minor("1.7.0") == "1.7"
536+
537+
538+
class TestDetectEolPage:
539+
def test_older_versions_url(self):
540+
url = "https://docs.getdbt.com/docs/dbt-versions/core-upgrade/Older versions/upgrading-to-v1.5.md"
541+
assert detect_eol_page(url) is True
542+
543+
def test_url_encoded_older_versions(self):
544+
url = "https://docs.getdbt.com/docs/dbt-versions/core-upgrade/Older%20versions/upgrading-to-v1.3.md"
545+
assert detect_eol_page(url) is True
546+
547+
def test_current_upgrade_url(self):
548+
url = "https://docs.getdbt.com/docs/dbt-versions/core-upgrade/upgrading-to-v1.9.md"
549+
assert detect_eol_page(url) is False
550+
551+
def test_regular_docs_url(self):
552+
url = "https://docs.getdbt.com/docs/build/models.md"
553+
assert detect_eol_page(url) is False
554+
555+
556+
class TestVersionInSearchResponse:
557+
@pytest.mark.asyncio
558+
async def test_search_includes_dbt_project_version(
559+
self, versioned_context, mock_client
560+
):
561+
mock_client.search_index.return_value = [
562+
{"title": "Models", "url": "https://docs.getdbt.com/docs/build/models"},
563+
]
564+
result = await search_product_docs.fn(versioned_context, "models")
565+
assert result.dbt_project_version == "1.8"
566+
567+
@pytest.mark.asyncio
568+
async def test_search_version_none_when_not_set(self, context, mock_client):
569+
mock_client.search_index.return_value = [
570+
{"title": "Models", "url": "https://docs.getdbt.com/docs/build/models"},
571+
]
572+
result = await search_product_docs.fn(context, "models")
573+
assert result.dbt_project_version is None
574+
575+
@pytest.mark.asyncio
576+
async def test_search_empty_query_still_has_version(self, versioned_context):
577+
result = await search_product_docs.fn(versioned_context, "")
578+
assert result.dbt_project_version == "1.8"
579+
assert result.error is not None
580+
581+
582+
class TestVersionInGetPagesResponse:
583+
@pytest.mark.asyncio
584+
async def test_get_pages_includes_dbt_project_version(
585+
self, versioned_context, mock_client
586+
):
587+
mock_client.get_page.return_value = "# Page Content"
588+
result = await get_product_doc_pages.fn(
589+
versioned_context, ["/docs/build/models"]
590+
)
591+
assert result.dbt_project_version == "1.8"
592+
593+
@pytest.mark.asyncio
594+
async def test_get_pages_version_none_when_not_set(self, context, mock_client):
595+
mock_client.get_page.return_value = "# Page Content"
596+
result = await get_product_doc_pages.fn(context, ["/docs/build/models"])
597+
assert result.dbt_project_version is None
598+
599+
600+
class TestEolPageAnnotation:
601+
@pytest.mark.asyncio
602+
async def test_fetched_eol_page_has_warning(self, context, mock_client):
603+
mock_client.get_page.return_value = "# Upgrading to v1.5\n\nOld content."
604+
result = await get_product_doc_pages.fn(
605+
context,
606+
[
607+
"https://docs.getdbt.com/docs/dbt-versions/core-upgrade/Older versions/upgrading-to-v1.5"
608+
],
609+
)
610+
page = result.pages[0]
611+
assert page.version_note is not None
612+
assert "end-of-life" in page.version_note
613+
assert page.content.startswith(">>> VERSION NOTICE:")
614+
615+
@pytest.mark.asyncio
616+
async def test_fetched_current_page_no_annotation(self, context, mock_client):
617+
mock_client.get_page.return_value = "# Models\n\nCurrent content."
618+
result = await get_product_doc_pages.fn(context, ["/docs/build/models"])
619+
page = result.pages[0]
620+
assert page.version_note is None
621+
assert not page.content.startswith(">>>")
622+
623+
503624
class TestProductDocsRegistration:
504625
@pytest.mark.asyncio
505626
async def test_tools_registered_by_default(self, env_setup):

0 commit comments

Comments
 (0)