feat: own the dbt Semantic Layer client, talking to the API directly - #111
feat: own the dbt Semantic Layer client, talking to the API directly#111josep-reyero wants to merge 8 commits into
Conversation
lfx-mcp reaches the semantic layer by calling lfx-lens over HTTP, which
then calls the dbt Semantic Layer. lfx-lens is the text-to-SQL service
and performs no per-user authorization on those routes, so the hop buys
nothing and costs latency, a failure mode, and a second release train.
This is the client that removes it.
There is no Go SDK for the dbt Semantic Layer. Both reference
implementations, lfx-lens and dbt Labs' own dbt-mcp, are Python and
split the transport: GraphQL for metadata, Arrow Flight over gRPC for
execution. This client uses GraphQL for both, which avoids an Arrow,
gRPC and session-lifecycle dependency for no loss at our volumes: the
tools cap limit at 500, well inside the API's 1024-row page.
Ported from lfx-lens ai/services/dbt_semantic_layer.py, minus the
project scope check, which queries Snowflake and is not a security
boundary given the tools are staff-gated and project_slug is optional.
A live parity harness sits behind the 'parity' build tag. It caught
three things a stub could not:
- The published docs name the createQuery argument 'order'; the
deployed schema calls it 'orderBy'. Every ordered query would have
failed. Schema introspection is the source of truth.
- Metric values decoded as float64 reached the model as 4.239559e+06
rather than 4239559. Numbers are now decoded as json.Number.
- Callers write time grains as 'metric_time__year', but the GraphQL
API takes the grain as its own field. The Arrow path hides this, so
every time series query would have broken.
Verified against the live semantic layer: 59 of 59 allowlisted metrics
reachable, country__lf_region returns the 9 expected values including
'Asia Pacific', a 'viet' search returns 'Viet Nam', and both search
fallbacks rescue a plural and a natural-language query. Warm queries
return in about 1.6s.
Issue: LFXV2-2940
Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
The semantic layer tools now call the dbt Semantic Layer in process through internal/dbtsl, instead of proxying through lfx-lens over HTTP. internal/tools/lens.go held both surfaces in 664 lines. It shrinks to query_lfx_lens alone, which still routes through lfx-lens and keeps its LFXMCP_LENS_API_* settings. Everything else moves to semanticlayer.go with its own config type. The tool descriptions and help texts move verbatim, since they carry prompt engineering that took three iterations to land and three tests guard them. The dbt client is constructed in a top-level block in main.go rather than nested inside the LFX API guard that wraps the lens client: it authenticates with a static service token and has no Auth0 dependency, so nesting it there would leave it unconfigured for an unrelated reason. It is deliberately not given the serviceapi debug transport, which dumps the Authorization header and would print the dbt service token into production logs, where debug traffic is currently on. project_slug is removed from the query tool rather than kept as an accepted no-op. Its description promised the where clause was 'validated against that foundation's subtree', and with the Snowflake scope check gone nothing enforces that. A parameter that silently does nothing while reading as a scoping guarantee is the same class of failure as a filter value that returns zero rows instead of an error. Scoping now happens in the where clause like any other filter, which is how it always actually worked. This freed 69 bytes of the query tool's 2048-byte description budget. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Adds LFXMCP_DBT_SL_HOST and LFXMCP_DBT_SL_ENVIRONMENT_ID as values, and LFXMCP_DBT_SL_TOKEN from the lfx-mcp-secrets Secret under the dbt_semantic_service_token key. No ExternalSecret change is needed: it already merges every AWS secret tagged service-lfx-mcp into that Secret, and the dbt token gains that tag in lfx-secrets-management (LFXV2-2939). The token env var is optional, so a cluster without the tag applied yet still starts, with the semantic layer tools reporting themselves unconfigured. Also documents the two data paths in AGENTS.md, since query_lfx_lens and the semantic layer tools no longer share a backend, and splits them in the README tool tables. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
Pull request overview
Moves semantic-layer operations from LFX Lens into a direct dbt GraphQL client, reducing service dependencies and latency.
Changes:
- Adds the
internal/dbtslclient with caching, discovery, allowlisting, and query execution. - Separates semantic-layer tools from Lens and adds extensive tests.
- Adds server, Helm, and documentation configuration for dbt credentials.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
README.md |
Documents the standalone semantic-layer tools. |
internal/tools/semanticlayer.go |
Implements direct semantic-layer MCP tools. |
internal/tools/semanticlayer_test.go |
Tests tool behavior and schemas. |
internal/tools/lens.go |
Removes semantic-layer routing from Lens. |
internal/tools/lens_test.go |
Removes migrated Lens tests. |
internal/tools/csv.go |
Extracts CSV argument parsing. |
internal/dbtsl/similarity.go |
Implements fuzzy similarity scoring. |
internal/dbtsl/search.go |
Adds search fallbacks. |
internal/dbtsl/query.go |
Implements GraphQL query execution and polling. |
internal/dbtsl/parity_live_test.go |
Adds live parity tests. |
internal/dbtsl/metadata.go |
Fetches metric and dimension metadata. |
internal/dbtsl/dimensionvalues.go |
Implements guarded dimension-value discovery. |
internal/dbtsl/dbtsl_test.go |
Adds dbt client unit tests. |
internal/dbtsl/client.go |
Defines the GraphQL client and transport. |
internal/dbtsl/cache.go |
Adds bounded TTL caches. |
internal/dbtsl/allowlist.go |
Defines exposed metrics and suggestions. |
cmd/lfx-mcp-server/main.go |
Configures and registers the dbt client. |
charts/lfx-mcp/values.yaml |
Adds dbt Helm values. |
charts/lfx-mcp/templates/deployment.yaml |
Injects dbt configuration and credentials. |
AGENTS.md |
Documents architecture and configuration. |
| queryArgs := dbtsl.QueryArgs{ | ||
| Metrics: metrics, | ||
| GroupBy: parseCSV(args.GroupBy), | ||
| OrderBy: parseCSV(args.OrderBy), | ||
| Limit: args.Limit, |
There was a problem hiding this comment.
Fixed in ca8fec3, and this turned out to be the most serious finding on the PR — thank you.
You were right that nothing enforced the ceiling, and the consequence is worse than an upstream default row count. The dbt SL GraphQL API pages results at ~1024 rows. Against the live environment, total_activities grouped by activity_project_id__organization_name with no limit returned exactly 1024 rows and reported row_count: 1024 — a truncated answer indistinguishable from a complete one, which the model would then present as fact. The Arrow Flight transport the Python implementation used streams the whole result, so this is a hazard the port introduced rather than inherited.
Two fixes: the tool now defaults an omitted or negative limit to the 500 it advertises, and Query follows totalPages to the end so a paged result is never silently cut. Same query now returns 500. Test: TestSemanticLayerDefaultsTheLimitToTheAdvertisedCeiling.
| interval := pollInitialInterval | ||
| for { | ||
| result, err := c.pollQuery(ctx, queryID) |
There was a problem hiding this comment.
Fixed in ca8fec3. Confirmed: stdio runs on a background context and main.go sets only ReadHeaderTimeout, so nothing bounded the loop. Query now carries its own queryMaxWait budget while still honouring earlier caller cancellation.
Reviewing this also surfaced the opposite failure alongside it: pollQuery returning any error aborted immediately, so a single 502 mid-poll discarded a query still running upstream and about to succeed. The Python route retried once on transport errors, so that was a regression. The loop now absorbs a bounded number of consecutive transport errors, while a FAILED status still aborts at once — the same split the Python had between QueryFailedError and transport errors. Tests: TestQueryToleratesATransientPollFailure, TestQueryGivesUpAfterRepeatedPollFailures, TestQueryDoesNotRetryAnApplicationFailure.
| if disallowed := ValidateMetrics(metricNames); len(disallowed) > 0 { | ||
| return nil, &UnknownDimensionError{Message: fmt.Sprintf( | ||
| "Metrics not in allowlist: %s.", strings.Join(disallowed, ", "), | ||
| )} | ||
| } |
There was a problem hiding this comment.
Fixed in ca8fec3, and I confirmed the premise against the live API rather than assuming it: FetchDimensions(ctx, nil) returns 295 dimensions — every dimension in the environment, so an empty metric list does exactly what you describe.
One correction to the severity: it is not reachable today. The only caller, handleSLGetDimensionValues, rejects an empty metrics before this point. But the gate is documented as an invariant of this function, and it was not one, so it now holds where it is claimed. Test: TestFetchDimensionValuesRejectsAnEmptyMetricList.
| if runLengths[j] > length { | ||
| length = runLengths[j] | ||
| aStart = i - length + 1 | ||
| bStart = j - length + 1 | ||
| } |
Review of the branch surfaced four defects, three of them found by running the code against the live semantic layer rather than a stub. Results were silently truncated. The GraphQL API pages at about 1024 rows where Arrow Flight, which the Python implementation used, streams the whole result, so this is a hazard the port introduced rather than inherited. createQuery only sent a limit when one was given, and the tool never defaulted it, so the common case of a model omitting limit issued an unbounded query. Live, 'total_activities grouped by organization_name' returned exactly 1024 rows and reported row_count 1024: a truncated answer indistinguishable from a complete one. Query now follows totalPages to the end, and the tool defaults an omitted or negative limit to the 500 its description advertises. The same query now returns 500. similarityRatio did not reproduce difflib. The j scan ran backwards, which is the compact way to write the rolling array but inverts difflib's tie-break: among equal-length runs it kept the latest j where difflib keeps the earliest, stranding the rest of a against a shorter tail. Measured against real difflib output, 25% of ordered pairs of allowlisted metric names disagreed. The point of hand-porting rather than using an edit distance was that suggestions would not change in the port, so this defeated the exercise. The test did not catch it because all eight hand-picked pairs happened to be ones where the tie-break does not bite; it is replaced by a sweep of all 3490 pairs against ratios generated by difflib itself, which fails on 879 of them against the old implementation. The poll loop had no bound of its own and no tolerance for a transient failure. stdio runs on a background context and the HTTP server sets only ReadHeaderTimeout, so a query stuck in RUNNING polled until the process exited; and a single 502 mid-poll discarded a query that was still running upstream, which the Python route had retried. Query now carries its own budget and absorbs a bounded number of consecutive transport errors, while a FAILED status still aborts at once. FetchDimensionValues accepted an empty metric list. Nothing in an empty list is disallowed, so it passed the allowlist check and then asked for dimensions scoped to no metric, which the live API answers with all 295 dimensions in the environment, including ones no allowlisted metric exposes. The only caller rejects an empty list first, so this was not reachable, but the gate is documented as living in the client. Also adds the per-file package comments the repo requires. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
|
Pushed ca8fec3, addressing the Copilot review plus an independent review pass. The one that matters: results were being silently truncated. The dbt SL GraphQL API pages at ~1024 rows. Arrow Flight, which the Python implementation used, streams the whole result — so this is a hazard the GraphQL port introduced rather than inherited, and the package doc actively hid it by asserting "pagination never engages". Live, against production data: A truncated result reported as complete is the same class of failure this tool set exists to prevent — it is the row-count analogue of a wrong filter literal returning zero rows instead of an error. Fixed on both sides: Also fixed
On the test that let the difflib bug through It used eight hand-picked pairs and passed both before and after the fix — every pair happened to be one where the tie-break does not bite. It is replaced by a sweep of the full domain against ratios generated by difflib itself, checked in as a fixture. I verified the replacement has teeth by running it against the old implementation (879/3490 fail) and the new one (0 fail). Worth stating plainly: the original test was the actual defect here, not just the code. Verification
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/dbtsl/metadata.go:58
- The dimension metadata query omits
queryableTimeGranularities, soget_dimensionscan silently drop supported grains. The dbt reference client requests both fields and combines them (for example, its fixtures havequeryableGranularities: ["day"]andqueryableTimeGranularities: ["month"]); this implementation currently reports only the former asqueryable_time_granularities. Request and decode both fields, then merge them when buildingDimensionInfo(and cover a grain present only inqueryableTimeGranularities).
queryableGranularities
internal/dbtsl/parity_live_test.go:82
- This parity check still passes when one or more allowlisted metrics disappear upstream; it only fails when the entire intersection is empty. That defeats the stated rename-detection purpose and allows a stale metric to remain accepted by
ValidateMetricseven though queries for it fail. Make a non-emptymissinglist fail the test rather than only logging it.
if len(missing) > 0 {
t.Logf("allowlisted but not reachable upstream: %v", missing)
}
revive rejects the branch: allowlist.go named a local slice 'close', shadowing the builtin. This is what was failing MegaLinter, not the jscpd duplication report, which .mega-linter.yml lists under DISABLE_ERRORS_LINTERS and is non-blocking. Renamed to 'closest'. metricsPaginated and dimensionsPaginated were read a page at a time with no check that a page was all there was. That is the same defect just fixed for query results, and it was inherited from the Python rather than introduced, but the consequence here is a partial allowlist: metrics that exist reported as unknown, and a suggested topic search returning nothing. FetchDimensions is worse, since FetchDimensionValues uses exactly that list to decide what may be read, so a missing dimension reads as one that does not exist. Both now follow totalPages. Live, both report totalPages 1 today, with all 295 dimensions on a single page, so this changes no current behaviour and only removes the assumption. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
Enumerating a slug dimension works but is the wrong tool for the job: the values come back capped and alphabetical, so the slug being looked for is usually not in the list, and it is slow to build (project_spine_slug measured 14.8s live against 1-2s for other dimensions). search_projects answers that question directly, by name. The guidance is a runtime note on the result rather than a line in the tool description. Description bytes are the scarcest resource on this surface — explore has 7 of its 2048 left, so this could only have been bought by deleting other guidance — and a note fires exactly when the mistake is made instead of being paid for on every call. It is emitted as its own content block so it cannot be read as part of the data, and it lives in internal/tools rather than internal/dbtsl, since the name of an MCP tool is not something the semantic layer client should know. pollMaxInterval stays at 2s. A 1s ceiling was considered: the cap only engages past about 3s, so it does nothing for the warm queries that return in 0.9-1.6s, and where it does engage it trades up to 1s of wall time for 10 extra requests on a 22.5s query — polling hardest exactly when the warehouse is slowest, to save 4%. Recorded next to the constant so it is not re-litigated. Also drops the lens test harness left dead by the lens.go split. It existed for the semantic layer HTTP tests, which now stub dbtsl directly. No coverage is lost: query_lfx_lens had no behavioural test before this branch either, only description assertions. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (3)
internal/dbtsl/dimensionvalues.go:134
searchis described as a plain substring, but%and_are inserted intoILIKEwithout escaping and therefore remain SQL wildcards. A literal search such as100%or_unknowncan match unrelated values. Escape LIKE metacharacters as well as quotes and add an explicitESCAPEclause supported by the Semantic Layer SQL dialect.
if search != "" {
args.Where = []string{fmt.Sprintf(
"{{ Dimension('%s') }} ILIKE '%%%s%%'", dimension, escapeSQLLiteral(search),
)}
internal/dbtsl/dimensionvalues.go:180
- This cannot reliably indicate truncation. Exactly
limittotal values is reported as truncated even when the result is complete; conversely, because NULLs are removed after the upstream limit is applied, a page containing NULL plus additional unseen values can produce fewer thanlimitvalues and be reported complete. Fetch an extra non-null row (for example, filter NULL and requestlimit+1) and deriveTruncatedbefore slicing the returned values tolimit.
func newDimensionValues(dimension string, values []string, limit int) *DimensionValues {
return &DimensionValues{
Dimension: dimension,
Values: values,
ValueCount: len(values),
Truncated: len(values) >= limit,
internal/dbtsl/allowlist.go:274
- The fuzzy fallback does not preserve
difflib.get_close_matchesranking for equal ratios.AllowedMetricNamesstarts in ascending order and this stable sort keeps ties ascending, while Python'snlargestover(score, candidate)ranks equal-score candidates in descending lexical order. Add the candidate name as a descending tie-break so the port does not reorder tied suggestions.
sort.SliceStable(closest, func(i, j int) bool { return closest[i].ratio > closest[j].ratio })
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/dbtsl/parity_live_test.go:82
- This parity check still passes when most allowlisted metrics disappear upstream:
missingis only logged, while the only assertion requires one reachable metric. A renamed or removed metric would therefore leave the production allowlist stale without failing the harness. Make the missing set fail the test.
if len(missing) > 0 {
t.Logf("allowlisted but not reachable upstream: %v", missing)
}
internal/dbtsl/dbtsl_test.go:1053
- This regression test does not distinguish
json.Numberfrom the oldfloat64decoding: 4,239,559 is exactly representable, andencoding/jsonmarshals that float as4239559without an exponent, so all current assertions also pass withUseNumberremoved. Assert the decoded type (or use an integer above 2^53) so the test actually protects exact numeric decoding.
func TestParseQueryResultKeepsLargeIntegersExact(t *testing.T) {
raw := `{"schema":{"fields":[{"name":"total_contributors","type":"integer"}],"primaryKey":[]},
"data":[{"total_contributors":4239559}]}`
The test guarding json.Number decoding passed with UseNumber removed, so it protected nothing. 4239559, the count actually observed in the wild, is exactly representable as a float64 and re-encodes as "4239559" with no exponent, so every assertion held either way. Uses 2^53+1 instead, which cannot survive a float64 round trip, and asserts the decoded type rather than inferring it from the rendering. Without UseNumber it now fails with 9.007199254740992e+15 — both the exponent and the lost +1. Same defect as the difflib test fixed earlier on this branch: a guard written from the observed symptom rather than the mechanism, passing in both directions. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
internal/tools/semanticlayer.go:66
- This standalone query-tool description says it covers all events and maintainers, while only redirecting narrative questions to Lens. Because either semantic-layer tool can be enabled independently, callers that only see this schema will miss the exceptions documented by the explore tool and route event sponsorships or maintainer trends here. Include those exceptions in this description as well.
const querySemanticLayerDescription = `The LFX Insights Semantic Layer is the query and data-exploration tool for Linux Foundation data; this half runs the query. Covers contributions, memberships, events, education, maintainers and project health — and anything sliced by country or region. ALWAYS call explore_lfx_semantic_layer first unless you already have the exact metric, dimension and entity names — never guess or assemble one: a wrong name errors, and a wrong filter value returns no rows rather than an error, so confirm literals with get_dimension_values. Use query_lfx_lens for narrative or "why" questions that do not reduce to a metric.
internal/dbtsl/allowlist.go:139
NoMetricsDetailrenders this list as recommended recovery topics, so every failed metric search currently tells callers to trysponsorship. That conflicts with the tool descriptions and tests that assign event sponsorships toquery_lfx_lens; remove it from the advertised fallback topics.
"sponsorship",
internal/dbtsl/parity_live_test.go:82
- This parity check still passes when one or more allowlisted metrics disappear upstream, so an upstream rename will not actually fail the harness despite the test's stated purpose. Make missing metrics a test failure to preserve the allowlist/API parity guarantee.
if len(missing) > 0 {
t.Logf("allowlisted but not reachable upstream: %v", missing)
}
README.md:293
- The new user-facing guidance is broader than the tool contract: event sponsorships and maintainer trends both reduce to metrics, but the semantic-layer descriptions explicitly route them to
query_lfx_lens. Qualify this recommendation so users are not directed to the wrong backend.
Prefer these over `query_lfx_lens` for anything that reduces to a metric: the
answer is repeatable and auditable, where generated SQL is neither.
| args.Where = []string{fmt.Sprintf( | ||
| "{{ Dimension('%s') }} ILIKE '%%%s%%'", dimension, escapeSQLLiteral(search), | ||
| )} |
There was a problem hiding this comment.
Confirmed against the live API and fixed in 2065510. Good catch — this was worse in practice than on paper.
search "_" -> 20 values: [Argentina Bahamas Bahrain Bangladesh Barbados ...] # every country
search "%" -> 20 values: [Angola Antarctica Antigua and Barbuda Austria ...] # everything
search "viet" -> 1 value: [Viet Nam] # correct
This action exists to hand back exact literals, because a filter naming a real dimension with an unknown value returns zero rows rather than an error. Returning a list of values that did not match inverts that: it reads as though they did.
One correction to the suggested fix. ESCAPE is supported, but backslash is the wrong escape character — it is also the escape character of the SQL string literal, so it is consumed during string parsing before ILIKE ever sees it. I tried it first and the pattern '%\_%' ESCAPE '\\' matched Germany and Yemen, neither of which contains an underscore. Using !, which has no meaning inside a string literal, gives the right result: literal _ and % searches now return 0 rows and viet still returns Viet Nam.
Worth noting where the test for this landed. A stub server cannot catch it — it echoes whatever rows the test queued, so the ILIKE pattern is never evaluated. The unit test only pins the clause that gets built; the one that would actually have caught the bug is in the parity harness (TestLiveDimensionValuesTreatsSearchAsALiteral), where the real ILIKE runs and a literal _ search has to return nothing.
search is documented as a plain substring, but % and _ went into the ILIKE pattern unescaped and stayed wildcards. Live, a search for "_" returned every country name in the environment and "%" did the same. That is the worst failure this action can have: it exists to hand back exact literals so a filter matches something, and a list of unrelated values reads as though those values matched. Metacharacters are now escaped and an ESCAPE clause accompanies the pattern. The escape character is '!', not the obvious backslash: backslash is also the escape character of the SQL string literal, so it is consumed during string parsing before ILIKE sees it. Verified live, a '%\_%' pattern matched Germany and Yemen. The unit tests could not have caught this, and the new one only pins the clause that gets built: a stub echoes whatever rows the test queued, so nothing about the pattern is actually evaluated. The test that matters is in the parity harness, where the real ILIKE runs and a literal search for "_" must return nothing. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais <josepreyero@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/dbtsl/query.go:361
- The nested
GroupByInputin an order clause also requirestimeGranularity;grainis not the GraphQL field name. As written, ordering bymetric_time__year(or another time grain) is rejected even if the corresponding group-by is fixed.
groupBy["grain"] = grain
| input := map[string]any{"name": name} | ||
| if base, grain, ok := splitTimeGrain(name); ok { | ||
| input["name"] = base | ||
| input["grain"] = grain |
There was a problem hiding this comment.
Checked this against the deployed schema and it does not hold — grain is a real field and the queries run.
Introspecting GroupByInput on our environment:
name: String (NON_NULL)
timeGranularity: String (SCALAR)
grain: TimeGranularity (ENUM)
datePart: DatePart (ENUM)
alias: String (SCALAR)
Both exist. timeGranularity is a loose string, grain is the typed enum, and the enum is the better choice here precisely because the server validates it — a bad grain is rejected at the API rather than silently producing the wrong bucket.
The claim that this "makes GraphQL reject every grouped time-series query before execution" is testable, so I tested it. Raw mutation with grain: YEAR straight at the API returns a queryId and compiles, no error. End to end through this client:
--- PASS: TestLiveQueryWithTimeGrain (1.12s)
columns: [METRIC_TIME__YEAR TOTAL_CONTRIBUTORS], rows: 5
map[METRIC_TIME__YEAR:2030-01-01T00:00:00.000Z TOTAL_CONTRIBUTORS:1]
...
That test is in the parity harness and runs against the live semantic layer, so it is exactly the assertion this comment predicts would fail.
I think the reasoning is from the current Python SDK's serialization rather than our deployed GraphQL schema. Worth flagging that the two disagree in the other direction too: the published docs name the createQuery ordering argument order, while the deployed schema calls it orderBy — introspection caught that one during this port, and it would have broken every ordered query. Introspection against the live endpoint is the source of truth for this client, not the SDK or the docs.
No change made.
Problem
A semantic layer call travels MCP client → lfx-mcp → lfx-lens over HTTP, and
only then to the dbt Semantic Layer. lfx-lens is the text-to-SQL service; the
semantic layer lives there for historical reasons, and the hop buys nothing,
because lfx-lens performs no per-user authorization on those routes.
What it costs is real: extra latency, an extra failure mode, and a second
release train for every semantic layer change. That last one already bit us —
lfx-mcp v0.12.0 sat inert in production until lfx-lens v0.5.5 shipped, because
the MCP tool called an endpoint the deployed lens did not yet have.
What this does
New
internal/dbtslpackage, peer tointernal/lfxv2andinternal/serviceapi: GraphQL transport, TTL caches, metric and dimensionmetadata, the
INSIGHTS_METRICS_ALLOWLISTwith ranked suggestions and topicwords, the singular-stem and per-word search fallbacks, query execution, and
gated dimension value discovery. Ported from
lfx-lens/ai/services/dbt_semantic_layer.py.De-branded tool surface.
internal/tools/lens.goheld both surfaces in 664lines; it shrinks to
query_lfx_lensalone, which still routes through lfx-lensand keeps its
LFXMCP_LENS_API_*settings. The semantic layer moves tosemanticlayer.gowith its own config type. Tool names are unchanged — theycarry no lens branding and are already in the production allowlist and in live
client prompts.
Three new settings,
LFXMCP_DBT_SL_HOST,LFXMCP_DBT_SL_ENVIRONMENT_IDand
LFXMCP_DBT_SL_TOKEN, wired in a top-level block rather than nested insidethe Auth0 client-credentials guard that wraps the lens client: this client uses
a static service token and has no Auth0 dependency, so nesting it there would
leave it unconfigured for an unrelated reason.
Design note: GraphQL for execution too
There is no Go SDK for the dbt Semantic Layer. Both reference implementations
are Python and split the transport — lfx-lens and dbt Labs' own
dbt-mcpbothuse the
dbtslSDK with GraphQL for metadata and Arrow Flight for execution.This uses GraphQL for both, via
createQuerythen pollingqueryforjsonResult. That avoids taking on Arrow, gRPC and session lifecycle for nogain at our volumes: the tool caps
limitat 500, well inside the API's1024-row page, so pagination never engages.
Measured: transport is not the bottleneck. The same query runs 22.7s cold and
1.6s warm — a 14× difference that can only be the warehouse. Metadata calls
are 0.4–1.2s. The poll loop adds ≤0.2s on fast queries and ≤2s on slow ones.
Design note:
project_slugis removedNot kept as a no-op. Its description promised the where clause was "validated
against that foundation's subtree", and the Snowflake scope check that enforced
that is gone — it queried
ANALYTICS.SILVER_DIM.PROJECT_SPINEand was the onlypart of the lens implementation that was not self-contained.
It was never a security boundary: the tools are staff-gated and
project_slugwas optional, so a caller bypassed the check by omitting it. Keeping the
parameter while nothing enforces it would mean a caller passing
project_slug: "cncf"reads a global result as CNCF-scoped — a silent wronganswer, the same failure class this epic exists to remove.
Scoping now goes in the
whereclause, which is how it always actually worked(
project_slugonly ever validated your filter, it never applied one). Thisfreed 69 bytes of the query tool's 2048-byte description budget.
When a non-staff tier arrives, scope should be rebuilt on OpenFGA via
internal/lfxv2/access_check.go, not on a warehouse table.Security note
The dbt client is deliberately not given the
serviceapidebug transport.That calls
httputil.DumpRequestOut(req, true), which includes theAuthorizationheader, and production runs withdebugTraffic: true— it wouldprint this long-lived service token into production logs.
Separately and pre-existing: the lens and onboarding clients already dump
their Auth0 bearer tokens in production under that same setting. Out of scope
here, but it should probably be its own ticket.
Verification
CI runs only MegaLinter and the license-header check, so for the record:
make checkclean; 62 tests pass, 85.4% coverage oninternal/dbtsl.paritybuild tag(
go test -tags parity ./internal/dbtsl/) passes against the real semanticlayer: 59/59 allowlisted metrics reachable,
country__lf_regionreturns the9 expected values including
'Asia Pacific', avietsearch returns'Viet Nam', and both search fallbacks rescue a plural and anatural-language query.
global and CNCF-scoped: memberships, contributions, events, education,
maintainers, project health; multi-metric cross-domain outer joins; year
trends; MetricFlow categorical and time filters. The PII gate rejects
user__email, and an unknown metric returns ranked suggestions.The parity harness earned its keep — it caught three defects a stub could not:
createQueryargumentorder; the deployedschema calls it
orderBy. Every ordered query would have failed.float64reached the model as4.239559e+06rather than
4239559. Now decoded asjson.Number.metric_time__year, but the GraphQL API takesthe grain as its own field. The Arrow path hides this, so every time-series
query would have broken.
Deploy order
access to the dbt token, and production must be synced before the
production chart bump or the tools break there.
v0.13.0(minor: substantially changed tools), then bumpapps/prod/lfx-mcp.yamlfrom0.12.0.Rollback is clean: revert the pin. lfx-lens still serves the semantic layer
routes until LFXV2-2941, which is sequenced after production verification.
Issue: LFXV2-2940
🤖 Generated with Claude Code