From 66fbd22ddc529aa23292d628a42f0af3aa8c9397 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Tue, 28 Jul 2026 17:15:29 +0200 Subject: [PATCH 1/4] feat(tools): route country/region questions to the semantic layer Regional data now exists across activities, memberships, event registrations and enrollments, but the tool description sent all membership questions to query_lfx_lens, so regional membership breakdowns were routed away from the only path that has the data. Carve country/region breakdowns out of that routing rule and add a tip naming the two dimension shapes (country__* for people, organization_lf_region for organization HQ). Membership metrics exceed the 15-metric threshold at which list_metrics inlines dimensions, so the tip also points at get_dimensions explicitly. Both lines live in the main tool description, so clients see them without calling describe. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Josep Garcia-Reyero Sais --- internal/tools/lens.go | 3 ++- internal/tools/lens_test.go | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/tools/lens.go b/internal/tools/lens.go index aff860d..8d151d6 100644 --- a/internal/tools/lens.go +++ b/internal/tools/lens.go @@ -161,7 +161,7 @@ const ( Best for direct, well-scoped questions: totals, counts, averages, breakdowns by a single dimension, and time series (e.g. "total activities for CNCF", "active maintainers by organization", "health score trend by month", "total enrollments by course"). This is also the right tool for contributor/activity questions — it has full contributor data including names, organizations, and activity breakdowns. Use query_lfx_lens INSTEAD for: -- All membership questions (memberships model works better with ad-hoc SQL) +- Membership questions, EXCEPT country/region breakdowns (see the country/region tip) - Maintainer names, maintainer+contribution (activities data) joins, or maintainer trends - Open-ended or exploratory analysis (e.g. "which projects need attention?") - Questions involving subprojects (e.g. "health scores by project") @@ -197,6 +197,7 @@ Tips: - Contributors and code-related data (commits, PRs, insertions, deletions) are in the activities model — search for "activities" in list_metrics. IMPORTANT: Questions about contributors and code-related topics that do not involve maintainers should prefer this tool. - Events metrics use project_name rather than project_slug for filtering. +- Country/region breakdowns belong here for ANY topic — contributors, organizations, memberships, event registrations, enrollments. A person's country uses country__* (e.g. country__lf_region); an organization's HQ uses organization_lf_region. Membership metrics don't inline dimensions, so call get_dimensions with search "country" or "region". - ` // semanticLayerSlotSearchProjects: search_projects guidance. diff --git a/internal/tools/lens_test.go b/internal/tools/lens_test.go index 071d2b2..263804f 100644 --- a/internal/tools/lens_test.go +++ b/internal/tools/lens_test.go @@ -213,6 +213,13 @@ func TestSemanticLayerDescription(t *testing.T) { "may be omitted for global or cross-foundation questions", "For membership metrics, a Linux Foundation ('tlf') filter only captures direct LF memberships", "Activity metrics are fanned out to foundations", + // Regional guidance: country/region questions must route here for every + // topic, including memberships, whose dimensions are not inlined. + "Country/region breakdowns belong here for ANY topic", + "country__lf_region", + "organization_lf_region", + "call get_dimensions with search \"country\" or \"region\"", + "Membership questions, EXCEPT country/region breakdowns", } { if !strings.Contains(semanticLayerDescription, want) { t.Errorf("description missing %q", want) From 88c461e5c1aedc53d7310b17808240ad529ccb9d Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Tue, 28 Jul 2026 17:19:31 +0200 Subject: [PATCH 2/4] fix(tools): carry the regional exception into the action schema text The action property's jsonschema description still told clients to use query_lfx_lens for memberships, with no regional exception. That text ships with tools/list alongside the tool description, so clients were getting contradictory routing guidance and regional membership prompts could still be sent to the wrong tool. Add the same carve-out there, and assert the registered schema's action description rather than only the tool description, so the two cannot drift apart again. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Josep Garcia-Reyero Sais --- internal/tools/lens.go | 2 +- internal/tools/lens_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/tools/lens.go b/internal/tools/lens.go index 8d151d6..f6608b9 100644 --- a/internal/tools/lens.go +++ b/internal/tools/lens.go @@ -233,7 +233,7 @@ func RegisterSemanticLayer(server *mcp.Server) { // SemanticLayerLFXLensArgs defines the input for the unified semantic layer tool. type SemanticLayerLFXLensArgs struct { ProjectSlug string `json:"project_slug,omitempty" jsonschema:"Optional project slug from search_projects (e.g. 'cncf'). When provided, where-clause project filters are validated against that foundation's subtree. May be omitted for global or cross-foundation queries."` - Action string `json:"action" jsonschema:"Required. Start with list_metrics — often enough to go straight to query. Best for activities, maintainer counts, health scores, projects, events, education. For memberships, maintainer names/trends, open-ended, subproject, or exploratory questions use query_lfx_lens instead. Values: list_metrics, get_dimensions, query, describe"` + Action string `json:"action" jsonschema:"Required. Start with list_metrics — often enough to go straight to query. Best for activities, maintainer counts, health scores, projects, events, education. For memberships (except country/region breakdowns), maintainer names/trends, open-ended, subproject, or exploratory questions use query_lfx_lens instead. Values: list_metrics, get_dimensions, query, describe"` Target string `json:"target,omitempty" jsonschema:"For action=describe only: which action to get help for (e.g. 'query')"` Metrics string `json:"metrics,omitempty" jsonschema:"Comma-separated metric names from list_metrics (for get_dimensions and query)"` Search string `json:"search,omitempty" jsonschema:"Search term to filter results (for list_metrics and get_dimensions)"` diff --git a/internal/tools/lens_test.go b/internal/tools/lens_test.go index 263804f..40700cc 100644 --- a/internal/tools/lens_test.go +++ b/internal/tools/lens_test.go @@ -286,6 +286,30 @@ func schemaRequired(t *testing.T, tool *mcp.Tool) []string { return schema.Required } +// schemaPropertyDescription returns the description a client sees for one +// input-schema property. These travel with tools/list alongside the tool +// description, so guidance in them must not contradict it. +func schemaPropertyDescription(t *testing.T, tool *mcp.Tool, property string) string { + t.Helper() + raw, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("failed to marshal input schema: %v", err) + } + var schema struct { + Properties map[string]struct { + Description string `json:"description"` + } `json:"properties"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("failed to parse input schema: %v", err) + } + prop, ok := schema.Properties[property] + if !ok { + t.Fatalf("input schema has no %q property", property) + } + return prop.Description +} + func TestRegisterSemanticLayer_Schema(t *testing.T) { tool := listSemanticLayerTool(t) @@ -302,6 +326,14 @@ func TestRegisterSemanticLayer_Schema(t *testing.T) { if !strings.Contains(tool.Description, "project_slug is optional") { t.Error("description missing optional project_slug wording") } + + // The action property's own guidance ships with tools/list, so its + // membership routing must carry the same regional exception as the tool + // description — otherwise clients get contradictory instructions. + action := schemaPropertyDescription(t, tool, "action") + if !strings.Contains(action, "For memberships (except country/region breakdowns)") { + t.Errorf("action schema description missing the regional exception: %q", action) + } } func contains(list []string, want string) bool { From 50b231fd30e0477d783e22179fe6c27c0cda6531 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Tue, 28 Jul 2026 17:23:25 +0200 Subject: [PATCH 3/4] fix(tools): carry the regional exception into query_lfx_lens metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query_lfx_lens claimed "Always use this tool for: All membership questions" in its description, and "Always use for memberships" in its input schema. Both ship with tools/list, so a client saw them alongside the semantic layer's new carve-out and could still route a regional membership question to the SQL agent. Add the same exception to both, and cover them with a test — the existing assertions only inspected query_lfx_semantic_layer, so this contradiction was invisible to them. Descriptions only; the tool's behavior, handler and scoping are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Josep Garcia-Reyero Sais --- internal/tools/lens.go | 5 +++-- internal/tools/lens_test.go | 29 ++++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/tools/lens.go b/internal/tools/lens.go index f6608b9..1c0ed33 100644 --- a/internal/tools/lens.go +++ b/internal/tools/lens.go @@ -41,7 +41,8 @@ func RegisterQueryLFXLens(server *mcp.Server) { Description: `Ask natural language questions about a project's data using ad-hoc SQL generation. Always use this tool for: -- All membership questions (e.g. "current members", "membership revenue by tier", "churn rate") +- Membership questions (e.g. "current members", "membership revenue by tier", "churn rate"), EXCEPT country/region + breakdowns, which use query_lfx_semantic_layer - Maintainer names or maintainer+activities data joins, where activities data is the code activities model with code contributions, PRs, commits etc (e.g. "top maintainers by contributions", "who maintains Kubernetes?"). IMPORTANT: activities data (contributors, PRs, code contributions etc) not involving maintainers should use query_lfx_semantic_layer. @@ -71,7 +72,7 @@ Tips: // QueryLFXLensArgs defines the input for query_lfx_lens. type QueryLFXLensArgs struct { ProjectSlug string `json:"project_slug" jsonschema:"Project slug from search_projects (e.g. 'cncf') (required)"` - Input string `json:"input" jsonschema:"Natural language question. Always use for memberships, maintainer names/trends, open-ended analysis, subproject questions, cross-domain joins, and exploratory questions. Takes 15-30s. (required)"` + Input string `json:"input" jsonschema:"Natural language question. Always use for memberships (except country/region breakdowns), maintainer names/trends, open-ended analysis, subproject questions, cross-domain joins, and exploratory questions. Takes 15-30s. (required)"` } type lensWorkflowAdditional struct { diff --git a/internal/tools/lens_test.go b/internal/tools/lens_test.go index 40700cc..8dbe70e 100644 --- a/internal/tools/lens_test.go +++ b/internal/tools/lens_test.go @@ -236,12 +236,17 @@ func TestSemanticLayerDescription(t *testing.T) { func listSemanticLayerTool(t *testing.T) *mcp.Tool { t.Helper() + return listRegisteredTool(t, "query_lfx_semantic_layer", RegisterSemanticLayer) +} + +func listRegisteredTool(t *testing.T, name string, register func(*mcp.Server)) *mcp.Tool { + t.Helper() server := mcp.NewServer(&mcp.Implementation{ Name: "test-server", Version: "0.0.1", }, nil) - RegisterSemanticLayer(server) + register(server) ctx := context.Background() clientTransport, serverTransport := mcp.NewInMemoryTransports() @@ -263,11 +268,11 @@ func listSemanticLayerTool(t *testing.T) *mcp.Tool { t.Fatalf("ListTools failed: %v", err) } for _, tool := range res.Tools { - if tool.Name == "query_lfx_semantic_layer" { + if tool.Name == name { return tool } } - t.Fatal("query_lfx_semantic_layer not found in tool list") + t.Fatalf("%s not found in tool list", name) return nil } @@ -336,6 +341,24 @@ func TestRegisterSemanticLayer_Schema(t *testing.T) { } } +// TestQueryLFXLensDescription_RegionalException guards the other half of the +// routing contract: query_lfx_lens claims memberships, and both its +// description and its input schema ship with tools/list. If they keep saying +// "always use for memberships" unconditionally, clients get instructions that +// contradict the semantic layer's regional carve-out. +func TestQueryLFXLensDescription_RegionalException(t *testing.T) { + tool := listRegisteredTool(t, "query_lfx_lens", RegisterQueryLFXLens) + + if !strings.Contains(tool.Description, "EXCEPT country/region") { + t.Errorf("query_lfx_lens description missing the regional exception: %q", tool.Description) + } + + input := schemaPropertyDescription(t, tool, "input") + if !strings.Contains(input, "memberships (except country/region breakdowns)") { + t.Errorf("query_lfx_lens input schema missing the regional exception: %q", input) + } +} + func contains(list []string, want string) bool { for _, v := range list { if v == want { From 73a81d3a49c87332dc7664a12480253783e7f47a Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Tue, 28 Jul 2026 17:27:47 +0200 Subject: [PATCH 4/4] fix(tools): scope the regional tip to the domains that have the data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "ANY topic" overrode every routing exception above it, including event sponsorships — so "sponsorship revenue by region" matched both rules, and the tip claimed regional coverage the semantic layer does not have. Bound the tip to the four models that actually carry country data (activities, memberships, event registrations, enrollments) while keeping the routing force that motivated it: these breakdowns belong here even when the topic would otherwise route to query_lfx_lens. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Josep Garcia-Reyero Sais --- internal/tools/lens.go | 2 +- internal/tools/lens_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/tools/lens.go b/internal/tools/lens.go index 1c0ed33..150ee83 100644 --- a/internal/tools/lens.go +++ b/internal/tools/lens.go @@ -198,7 +198,7 @@ Tips: - Contributors and code-related data (commits, PRs, insertions, deletions) are in the activities model — search for "activities" in list_metrics. IMPORTANT: Questions about contributors and code-related topics that do not involve maintainers should prefer this tool. - Events metrics use project_name rather than project_slug for filtering. -- Country/region breakdowns belong here for ANY topic — contributors, organizations, memberships, event registrations, enrollments. A person's country uses country__* (e.g. country__lf_region); an organization's HQ uses organization_lf_region. Membership metrics don't inline dimensions, so call get_dimensions with search "country" or "region". +- Country/region breakdowns belong here for contributors, organizations, memberships, event registrations and enrollments — even when the topic would otherwise route to query_lfx_lens. A person's country uses country__* (e.g. country__lf_region); an organization's HQ uses organization_lf_region. Membership metrics don't inline dimensions, so call get_dimensions with search "country" or "region". - ` // semanticLayerSlotSearchProjects: search_projects guidance. diff --git a/internal/tools/lens_test.go b/internal/tools/lens_test.go index 8dbe70e..673a28a 100644 --- a/internal/tools/lens_test.go +++ b/internal/tools/lens_test.go @@ -215,7 +215,8 @@ func TestSemanticLayerDescription(t *testing.T) { "Activity metrics are fanned out to foundations", // Regional guidance: country/region questions must route here for every // topic, including memberships, whose dimensions are not inlined. - "Country/region breakdowns belong here for ANY topic", + "Country/region breakdowns belong here for contributors, organizations, memberships, event registrations and enrollments", + "even when the topic would otherwise route to query_lfx_lens", "country__lf_region", "organization_lf_region", "call get_dimensions with search \"country\" or \"region\"",