Skip to content

Commit 38bb88c

Browse files
authored
Merge branch 'main' into fix/dbt-stderr-on-failure-only
2 parents 7331c0e + d8f6199 commit 38bb88c

17 files changed

Lines changed: 498 additions & 53 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Bug Fix
2+
body: Add optional grain field to order_by in query_metrics; when provided it takes precedence over the matching group_by grain, preserving backward-compatible fallback when omitted
3+
time: 2026-04-10T13:45:00.000000-07:00
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Bug Fix
2+
body: 'fix: list_tools no longer triggers host elicitation, preventing ''No tools'' in dbt Core (CLI-only) setups after v1.17.0'
3+
time: 2026-05-07T18:42:13.228292+02:00
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: 'list_metrics now accepts a list of substrings in the search parameter (results are unioned and deduplicated, fetched in parallel) and prefixes its CSV with a # Note: line whenever description/metadata are trimmed because the response exceeded DBT_MCP_SL_MAX_RESPONSE_CHARS. Trimming is also now scoped to broad listings (result count above metrics_related_max) so a narrow result set always returns full description and metadata.'
3+
time: 2026-05-11T10:17:49.495415+02:00
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Under the Hood
2+
body: Sign release PR commits with github-actions bot for verified commit signatures
3+
time: 2026-01-28T11:02:30.412674+01:00

.github/workflows/create-release-pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ jobs:
9494
- name: Create Pull Request
9595
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
9696
with:
97+
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9798
title: "version: ${{ steps.package-version.outputs.version }}"
9899
branch: release/${{ steps.package-version.outputs.version }}
99100
commit-message: |

src/dbt_mcp/mcp/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def _is_multi_project(self) -> bool:
6868
(
6969
settings,
7070
_,
71-
) = await self.config.credentials_provider.get_credentials()
71+
) = await self.config.credentials_provider.inner_provider.get_credentials()
7272
except MissingHostError as e:
7373
logger.warning(
7474
"Could not resolve credentials — defaulting to single-project mode: %s",

src/dbt_mcp/prompts/semantic_layer/list_metrics.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ When the number of metrics is below the configured threshold (default: 10), each
66

77
When above the threshold, only metrics are returned. `metric_time` is a standard time dimension available on most metrics — you can often query directly without calling `get_dimensions` first. Call `get_dimensions` only when you need non-time dimensions or specific granularity details.
88

9+
For broad listings that exceed the size budget, the `description` and `metadata` columns are dropped to save tokens and the CSV is prefixed with one or more `# Note:` lines explaining what happened. When that happens, call `list_metrics` again with the `search` parameter to retrieve those fields for the specific metrics you care about — a narrow result set (at or below the related-metrics threshold) is always returned with full `description` and `metadata`, even if the text is verbose. `search` accepts either a single substring or a list of substrings; when a list is provided, metrics whose name matches **any** of the substrings are returned (deduplicated), so you can fetch details for several metrics in one call.
10+
911
If the user is asking a data-related or business-related question, use this tool as a first step.
1012

src/dbt_mcp/prompts/semantic_layer/query_metrics.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ entities to provide. You can call the list_metrics, get_dimensions,
77
and get_entities tools to get information about which metrics, dimensions,
88
and entities to use.
99

10-
When using the `order_by` parameter, you must ensure that the dimension or
11-
entity also appears in the `group_by` parameter. When fulfilling a lookback
10+
When using the `order_by` parameter, each item must refer to either a metric
11+
name or a field that also appears in `group_by`. For time dimensions, you may
12+
specify a `grain` in `order_by` independently from `group_by`; if omitted, it
13+
defaults to the grain of the matching `group_by` entry. `grain` only applies to
14+
time dimensions and should be omitted for metrics and categorical dimensions.
15+
When fulfilling a lookback
1216
query, prefer using order_by and limit instead of using the where parameter.
1317
A lookback query requires that the `order_by` parameter includes a descending
1418
order for a time dimension.
@@ -53,7 +57,7 @@ Thinking step-by-step:
5357
Parameters:
5458
metrics=["total_sales"]
5559
group_by=[{"name": "metric_time", "grain": "MONTH", "type": "time_dimension"}]
56-
order_by=[{"name": "metric_time", "descending": true}]
60+
order_by=[{"name": "metric_time", "grain": "MONTH", "descending": true}]
5761
limit=1
5862
</example>
5963
<example>
@@ -72,12 +76,12 @@ Thinking step-by-step:
7276
Parameters:
7377
metrics=["revenue"]
7478
group_by=[{"name": "customer_name", "type": "dimension"}, {"name": "metric_time", "grain": "QUARTER", "type": "time_dimension"}]
75-
order_by=[{"name": "metric_time", "descending": true}, {"name": "revenue", "descending": true}]
79+
order_by=[{"name": "metric_time", "grain": "QUARTER", "descending": true}, {"name": "revenue", "descending": true}]
7680
limit=5
7781
Follow-up Query (after verifying results):
7882
metrics=["revenue"]
7983
group_by=[{"name": "customer_name", "type": "dimension"}, {"name": "metric_time", "grain": "QUARTER", "type": "time_dimension"}]
80-
order_by=[{"name": "metric_time", "descending": true}, {"name": "revenue", "descending": true}]
84+
order_by=[{"name": "metric_time", "grain": "QUARTER", "descending": true}, {"name": "revenue", "descending": true}]
8185
limit=null
8286
</example>
8387
<example>
@@ -116,13 +120,13 @@ Thinking step-by-step:
116120
Parameters (initial query):
117121
metrics=["new_users"]
118122
group_by=[{"name": "metric_time", "grain": "WEEK", "type": "time_dimension"}]
119-
order_by=[{"name": "metric_time", "descending": false}]
123+
order_by=[{"name": "metric_time", "grain": "WEEK", "descending": false}]
120124
where="{{ TimeDimension('metric_time', 'WEEK') }} >= '2023-01-01' AND {{ TimeDimension('metric_time', 'WEEK') }} < '2024-01-01'"
121125
limit=4
122126
Follow-up Query (after verifying results):
123127
metrics=["new_users"]
124128
group_by=[{"name": "metric_time", "grain": "WEEK", "type": "time_dimension"}]
125-
order_by=[{"name": "metric_time", "descending": false}]
129+
order_by=[{"name": "metric_time", "grain": "WEEK", "descending": false}]
126130
where="{{ TimeDimension('metric_time', 'WEEK') }} >= '2023-01-01' AND {{ TimeDimension('metric_time', 'WEEK') }} < '2024-01-01'"
127131
limit=null
128132
</example>

src/dbt_mcp/semantic_layer/client.py

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ def default(self, obj):
7070
return json.dumps(records, indent=2, cls=ExtendedJSONEncoder)
7171

7272

73+
# Cap the number of substrings accepted by `list_metrics(search=[...])` so
74+
# an unbounded LLM-supplied list can't fan out into a burst of parallel
75+
# GraphQL requests against the Semantic Layer API.
76+
_MAX_SEARCH_TERMS = 20
77+
78+
79+
def _dedupe_metric_items(items: Any) -> list[Any]:
80+
"""Preserve first-seen order while filtering out duplicate metric names."""
81+
seen: set[str] = set()
82+
out: list[Any] = []
83+
for item in items:
84+
name = item.get("name")
85+
if name is None or name in seen:
86+
continue
87+
seen.add(name)
88+
out.append(item)
89+
return out
90+
91+
7392
class SemanticLayerClientProtocol(Protocol):
7493
def session(self) -> AbstractContextManager[Any]: ...
7594

@@ -126,13 +145,55 @@ def __init__(
126145
async def list_metrics(
127146
self,
128147
config: SemanticLayerConfig,
129-
search: str | None = None,
148+
search: str | list[str] | None = None,
130149
) -> ListMetricsResponse:
131-
metrics_result = await submit_request(
132-
config,
133-
{"query": GRAPHQL_QUERIES["metrics"], "variables": {"search": search}},
150+
# `search` may be a single substring or a list of substrings; for a list
151+
# we fan out one GraphQL call per substring, then merge & dedupe by name.
152+
search_terms: list[str | None]
153+
if isinstance(search, list):
154+
# Strip whitespace, drop empty values, and dedupe identical terms
155+
# (preserving first-seen order) so a whitespace-only or duplicated
156+
# term can't broaden the fan-out into redundant or no-filter calls.
157+
cleaned: list[str] = []
158+
seen_terms: set[str] = set()
159+
for raw in search:
160+
term = raw.strip()
161+
if not term or term in seen_terms:
162+
continue
163+
seen_terms.add(term)
164+
cleaned.append(term)
165+
if len(cleaned) > _MAX_SEARCH_TERMS:
166+
# Cap the fan-out so a runaway LLM call can't generate an
167+
# unbounded burst of parallel GraphQL requests.
168+
raise InvalidParameterError(
169+
f"`search` accepts at most {_MAX_SEARCH_TERMS} terms; "
170+
f"got {len(cleaned)}."
171+
)
172+
search_terms = list(cleaned) if cleaned else [None]
173+
else:
174+
# Mirror the list-path normalization for parity: a single-string
175+
# `search` is stripped, and an empty/whitespace-only string becomes
176+
# no filter (search=None).
177+
normalized = search.strip() if isinstance(search, str) else search
178+
search_terms = [normalized if normalized else None]
179+
180+
cheap_results = await asyncio.gather(
181+
*(
182+
submit_request(
183+
config,
184+
{
185+
"query": GRAPHQL_QUERIES["metrics"],
186+
"variables": {"search": term},
187+
},
188+
)
189+
for term in search_terms
190+
)
191+
)
192+
cheap_items = _dedupe_metric_items(
193+
item
194+
for r in cheap_results
195+
for item in r["data"]["metricsPaginated"]["items"]
134196
)
135-
metrics_count = len(metrics_result["data"]["metricsPaginated"]["items"])
136197
dimensionless_response = ListMetricsResponse(
137198
metrics=[
138199
MetricToolResponse(
@@ -142,26 +203,39 @@ async def list_metrics(
142203
description=m.get("description"),
143204
metadata=(m.get("config") or {}).get("meta"),
144205
)
145-
for m in metrics_result["data"]["metricsPaginated"]["items"]
206+
for m in cheap_items
146207
]
147208
)
148-
if metrics_count and metrics_count <= config.metrics_related_max:
149-
# Re-fetch with the same search filter using a single query that includes
150-
# per-metric dimensions and entities. This avoids the N×2 parallel calls
151-
# approach: the nested GQL fields return per-metric data accurately (not
152-
# an intersection like dimensionsPaginated with multiple metrics would).
209+
210+
if cheap_items and len(cheap_items) <= config.metrics_related_max:
211+
# Re-fetch with per-metric dimensions and entities. Same fan-out:
212+
# the nested GQL fields return per-metric data accurately, unlike
213+
# dimensionsPaginated with multiple metrics which would intersect.
214+
# Fall back to the dimensionless response if the richer query
215+
# times out or otherwise fails (one slow term would otherwise
216+
# block the whole call via asyncio.gather).
153217
try:
154-
full_result = await submit_request(
155-
config,
156-
{
157-
"query": GRAPHQL_QUERIES["metrics_with_related"],
158-
"variables": {"search": search},
159-
},
160-
timeout=5.0,
218+
related_results = await asyncio.gather(
219+
*(
220+
submit_request(
221+
config,
222+
{
223+
"query": GRAPHQL_QUERIES["metrics_with_related"],
224+
"variables": {"search": term},
225+
},
226+
timeout=5.0,
227+
)
228+
for term in search_terms
229+
)
161230
)
162231
except Exception as e:
163232
logger.warning(f"Error fetching metrics with related: {e}")
164233
return dimensionless_response
234+
related_items = _dedupe_metric_items(
235+
item
236+
for r in related_results
237+
for item in r["data"]["metricsPaginated"]["items"]
238+
)
165239
return ListMetricsResponse(
166240
metrics=[
167241
MetricToolResponse(
@@ -173,7 +247,7 @@ async def list_metrics(
173247
dimensions=[d.get("name") for d in (m.get("dimensions") or [])],
174248
entities=[e.get("name") for e in (m.get("entities") or [])],
175249
)
176-
for m in full_result["data"]["metricsPaginated"]["items"]
250+
for m in related_items
177251
]
178252
)
179253
return dimensionless_response
@@ -336,18 +410,19 @@ def _get_order_bys(
336410
result: list[OrderBySpec] = []
337411
if order_by is None:
338412
return result
339-
queried_group_by = {g.name: g for g in group_by} if group_by else {}
413+
group_by_map = {g.name: g for g in group_by} if group_by else {}
340414
queried_metrics = set(metrics)
341415
for o in order_by:
342416
if o.name in queried_metrics:
343417
result.append(OrderByMetric(name=o.name, descending=o.descending))
344-
elif o.name in queried_group_by:
345-
selected_group_by = queried_group_by[o.name]
418+
elif o.name in group_by_map:
346419
result.append(
347420
OrderByGroupBy(
348-
name=selected_group_by.name,
421+
name=o.name,
349422
descending=o.descending,
350-
grain=selected_group_by.grain,
423+
grain=o.grain
424+
if o.grain is not None
425+
else group_by_map[o.name].grain,
351426
)
352427
)
353428
else:

src/dbt_mcp/semantic_layer/param_descriptions.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88

99
SEMANTIC_LAYER_PROJECT_ID = MULTI_PROJECT_PROJECT_ID_DESCRIPTION
1010

11-
SEMANTIC_SEARCH_METRICS = "Filter metrics by substring match against the metric name"
11+
SEMANTIC_SEARCH_METRICS = (
12+
"Filter metrics by substring match against the metric name. "
13+
"Accepts either a single substring or a list of substrings; when a list "
14+
"is provided, metrics matching any of the substrings are returned "
15+
"(deduplicated)."
16+
)
1217

1318
SEMANTIC_SEARCH_SAVED_QUERIES = (
1419
"Filter saved queries by substring match on name, label, or description"
@@ -29,8 +34,10 @@
2934
)
3035

3136
SEMANTIC_ORDER_BY = (
32-
"Sort keys; each item has `name` and `descending` (default false); "
33-
"order fields should appear in group_by when grouping"
37+
"Sort keys; each item has `name`, `descending` (default false), and optional "
38+
"`grain` for time dimensions (overrides the grain from group_by; falls back to "
39+
"the matching group_by grain when omitted); items may be metric names or "
40+
"group_by fields"
3441
)
3542

3643
SEMANTIC_WHERE = (

0 commit comments

Comments
 (0)