gasp-standard-mcp 1.14.0 → 1.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,24 +33,24 @@ Restart your MCP client after adding the config.
33
33
 
34
34
  | Tool | Description |
35
35
  |------|-------------|
36
- | `lookup_metric` | Full metric details: department, signal type, definition, formula, benchmarks, entities, common mistakes, sources and page URL. Matches exactly (formula id, name with or without its abbreviation, abbreviation, graph label, alias or slug), prefers the full record over a cross-reference stub and suggests close names (misspellings included) when nothing matches. A name two departments define (Escalation Rate) lists each candidate with department, formula id and URL; qualify by department ("Support Escalation Rate") or pass the formula id. Falls back to glossary terms when no metric matches |
37
- | `lookup_definition` | Glossary term lookup (e.g. Signal Type, Sensitivity Tier, Access Scope, Metric Owner, CEL): full definition, section, canonical or reference link and page URL. Matches exactly and suggests close terms instead of guessing |
38
- | `list_metrics` | List all metrics, optionally filtered by exact department name (case-insensitive; an unknown name returns the department list) and/or signal type. The Core tier is the Core department |
39
- | `get_formula` | Canonical formula in the GASP formula grammar, with unit, O/M forms, variants, inputs with polarity, raw fields with the datasets they live in and the enum filters each form applies (CAC-O reads `cost_function in (gtm, implementation, onboarding)`, CAC-M `cost_function = gtm`). Metrics that cannot be computed carry the reason |
40
- | `get_relationships` | Upstream/downstream relationships for metrics, entities or concepts. 21 relationship types across signal, lifecycle, ownership, interaction and financial flow categories. Metric names resolve as in `lookup_metric`, ambiguity included. An entity also lists every metric whose formula reads its datasets (from `entityMappings`), with department and URL |
41
- | `get_data_requirements` | Data requirements for implementing a metric or a department: each field with its type and the dataset it is read from, the staged flag, grain, source, page URL and example SQL. The metric resolves exactly as in `lookup_metric`; `department` (exact name) lists a department or picks between metrics sharing a name. Optional `breakdown` names a breakdown the metric declares (`by_plan` on MRR) and returns that breakdown's requirement: the dimension among the fields and example SQL with one row per value; an unknown name lists the declared ones. Optional `dialect` (`postgres`, `snowflake`, `bigquery`, `databricks`) writes the example SQL for that warehouse |
42
- | `search` | Whole-word search across metrics (names, aliases, definitions, formulas, fields) and glossary definitions. Up to 10 results, one per metric or term, ranked name and alias above definition above formula and field, each with what matched and its gaspwiki.com URL. A query that matches nothing returns the closest names as "Did you mean" |
43
- | `generate_query` | Compile warehouse-specific SQL from the metric's canonical formula and an adapter mapping. One scalar subquery per aggregate, each reading the table mapped for its dataset, with `:period_start` and `:period_end` parameters. Unmapped fields stay as `{{field@dataset}}` placeholders. Optional `breakdown` names a breakdown the metric declares (`by_plan` on MRR) and returns one row per value of its field; an unknown name lists the declared ones. Optional `dialect` (`postgres`, `snowflake`, `bigquery`, `databricks`; `postgres` also covers Redshift) picks the SQL syntax for date arithmetic, casts and percentiles; without it the adapter's `warehouse` decides when it names one of them, else postgres. The output says which dialect it used. Matches the metric exactly and suggests close names instead of guessing |
44
- | `validate_adapter` | Validate a GASP adapter mapping file. Applies the bundled adapter JSON Schema (draft 2020-12, version 1.1) first and lists each violation with its JSON path, then reports unknown fields and datasets, fields mapped to a dataset that does not carry them, dataset table conflicts, enum fields mapped without a transform and covered metrics without an owner. Computes coverage per metric against the exact field and dataset pairs its compiled SQL reads (full, partial or none) and ranks the highest-value next pairs to map |
45
- | `infer_drivers` | Diagnose what could explain an observed metric movement: ranked upstream candidates with paths and per-step edge classifications |
46
- | `explain_path` | Enumerate up to 5 edge chains between two metrics, with classification (metric_signal / structural / lifecycle / behavioral / assignment / generative) per step |
47
- | `coverage_gap` | Audit a metric set: one-hop upstream and downstream metrics missing from it, plus graph metrics with no path to or from it. Entities and concepts are ignored |
36
+ | `lookup_metric` | Full record for one metric: department, category, signal type, definition, formulas as written, the canonical formula with unit, forms, variants and breakdowns, benchmarks, validation rules, common mistakes, sources, aliases, the entities it measures, operating and market forms, the recommended sensitivity tier and access scope and the page URL. Matches exactly, ignoring case: formula id, graph node id, name with or without its abbreviation, abbreviation, graph label, alias or slug, with the full record preferred over a cross-reference stub. A name two departments define (Escalation Rate) lists each candidate with department, formula id and URL; prefix the department ("Support Escalation Rate") or pass the formula id. A glossary term returns its definition and a miss returns close names (misspellings included) |
37
+ | `lookup_definition` | Glossary term lookup (e.g. Signal Type, Sensitivity Tier, Access Scope, Metric Owner, CEL): full definition, section, canonical or reference link, where it is used and page URL. Matches the term, its slug or either part of "Term (Abbreviation)" exactly; a metric name points to `lookup_metric` and a miss returns close terms |
38
+ | `list_metrics` | Metric names grouped by department and category, optionally filtered by department (case-insensitive, spaces or hyphens; an unknown name returns close names and the department list) and signal type (`outcome`, `leading`, `efficiency`, `operational`, primary or secondary). The Core tier is the Core department |
39
+ | `get_formula` | Canonical formula in the GASP formula grammar, with unit, O/M forms, named variants, breakdowns (field, bucket and total mode), notes, input metrics with role and polarity, the fields and datasets the formula and each form read and the enum filters each form applies (CAC-O reads `cost_function in (gtm, implementation, onboarding)`, CAC-M `cost_function = gtm`), then the formula as written in the standard. Metrics that cannot be computed carry the reason |
40
+ | `get_relationships` | One node of the relationship graph (metric, entity or concept) with its direct edges: upstream, measured by, downstream and measures. 21 relationship types across signal, lifecycle, ownership, interaction and financial flow categories. Metric names resolve as in `lookup_metric`, ambiguity included; a metric outside the graph says so. An entity also lists every metric whose formula reads its datasets (from `entityMappings`), with department and URL |
41
+ | `get_data_requirements` | Data requirements for one metric, a department or every metric with a formula: per metric, each field and dataset pair with its type, the staged flag, source, grain, frequency, page URL and example SQL. The metric resolves exactly as in `lookup_metric`; `department` lists a department or picks between metrics sharing a name. Optional `breakdown` names a breakdown the metric declares (`by_plan` on MRR) and returns that breakdown's requirement: the dimension among the fields and example SQL with one row per value; an unknown name lists the declared ones. Optional `dialect` (`postgres` by default and for Redshift, `snowflake`, `bigquery`, `databricks`) writes the example SQL for that warehouse |
42
+ | `search` | Whole-word phrase search across metrics (names, aliases, definitions, formulas, fields, categories, departments) and glossary terms (terms, definitions, sections); the last word may take a plural or verb ending. Up to 10 results, one per metric or term, ranked name, alias and term first, then words within them, then definitions, then formulas and fields, each with what matched and its gaspwiki.com URL. A query that matches nothing returns up to 6 close names as "Did you mean" |
43
+ | `generate_query` | Compile warehouse SQL from the metric's canonical formula and an adapter. One scalar subquery per aggregate, each reading the table mapped for its dataset, with `:period_start` and `:period_end` date parameters (both inclusive). Unmapped pairs stay as `{{field@dataset}}` placeholders and are listed with any dataset lacking a table and any mapping conflict. Optional `breakdown` names a breakdown the metric declares (`by_plan` on MRR) and returns one row per value; an unknown name lists the declared ones. Optional `dialect` (`postgres`, `snowflake`, `bigquery`, `databricks`; `postgres` also covers Redshift) picks the SQL syntax for date arithmetic, casts and percentiles; without it the adapter's `warehouse` decides when it names one of them, else postgres. The output says which dialect it used |
44
+ | `validate_adapter` | Validate a GASP adapter. Applies the bundled adapter JSON Schema (draft 2020-12, versions 1.0 and 1.1) first and lists each violation with its JSON path, then reports unknown fields and datasets, fields mapped to a dataset that does not carry them, bare column names on fields that live on several datasets, dataset table conflicts, enum fields mapped without a transform, covered metrics without an owner and owners naming unknown metrics. Scores every metric with a formula against the exact field and dataset pairs its compiled SQL reads (full, partial with the missing pairs or none) and ranks up to 15 unmapped pairs by how many partially covered metrics need them |
45
+ | `infer_drivers` | Diagnose what could explain an observed metric movement: up to 25 upstream candidates ranked by attenuation score, each with its implied direction and path. Direction flips across `reduces` edges and negative-polarity inputs; a baseline input (MRR read at period start by NRR) is listed but not walked through. Metric nodes only; `maxDepth` 1 to 8 (default 6) |
46
+ | `explain_path` | Up to 5 forward edge chains from one graph node to another, shortest first, with edge type, classification (metric_signal / structural / lifecycle / behavioral / assignment / generative), attenuation and direction flips per step and the target's end direction per path. A baseline edge can only start a path |
47
+ | `coverage_gap` | Audit a metric set: metrics with a direct edge into or out of it that it leaves out, plus graph metrics with no path to or from it. Inputs that are ambiguous, outside the graph or not metrics are listed as unresolved with the reason |
48
48
 
49
49
  The traversal tools (`infer_drivers`, `explain_path`, `coverage_gap`) return paths, edge classifications and attenuation scores. A score says how strongly the graph connects two metrics, never how far one will move. The tools chain: `infer_drivers` picks candidates, `explain_path` shows the chain behind one and `coverage_gap` checks whether you measure it. For "what if" simulation, use the [TWIN Modifier](https://gaspwiki.com/v1/twin-modifier).
50
50
 
51
51
  ## Data
52
52
 
53
- Version 1.14.0.
53
+ Version 1.14.2.
54
54
 
55
55
  - 301 metrics across 13 departments
56
56
  - 93 glossary definitions (80 SaaS domain + 13 GASP framework)
package/build/derive.js CHANGED
@@ -182,7 +182,7 @@ export function computeDerived(data, current) {
182
182
  const temporal = JSON.parse(current.ontology);
183
183
  const sourcedFrom = ont.fields.flatMap((f) => (f.sources ?? []).map((s) => [f.id, s]));
184
184
  const belongs = ont.fields.flatMap((f) => (f.datasets ?? []).map((d) => [f.id, d]));
185
- L.push("// ═══════════════════════════════════════════════════════════════", "// GASP Standard — SaaS Metric Knowledge Graph (Full Ontology)", "// Generated from the GASP Standard (https://gaspwiki.com) by `npm run derive`", "// ═══════════════════════════════════════════════════════════════", "//", `// Nodes: ${metricNodes.length} Metrics, ${ont.entities.length} Entities, ${ont.concepts.length} Concepts, ${Object.keys(ont.datasets).length} Datasets, ${ont.fields.length} Fields, ${ont.sourceCategories.length} Source Categories`, `// Edges: ${metricEdges.length} metric relationships, ${measures.length} MEASURES, ${entityEdges.length} entity/concept relationships, ${belongs.length} BELONGS_TO, ${sourcedFrom.length} SOURCED_FROM`, "//", "// Relationship types (metric signal): DRIVES, REDUCES, PREDICTS, COMPONENT_OF, MEASURES", "// Relationship types (entity): " + Object.values(ont.entityRelationships?.edgeTypeClassification ?? {}).flat().join(", "), "// Relationship types (data lineage): BELONGS_TO (Field → Dataset), SOURCED_FROM (Field → SourceCategory)", "//", "// Import: paste into Neo4j Browser or pipe to cypher-shell", "// ═══════════════════════════════════════════════════════════════", "", "// Clean slate (optional, uncomment to clear existing data)", "// MATCH (n) DETACH DELETE n;", "");
185
+ L.push("// ═══════════════════════════════════════════════════════════════", "// GASP Standard: SaaS Metric Knowledge Graph (Full Ontology)", "// Generated from the GASP Standard (https://gaspwiki.com) by `npm run derive`", "// ═══════════════════════════════════════════════════════════════", "//", `// Nodes: ${metricNodes.length} Metrics, ${ont.entities.length} Entities, ${ont.concepts.length} Concepts, ${Object.keys(ont.datasets).length} Datasets, ${ont.fields.length} Fields, ${ont.sourceCategories.length} Source Categories`, `// Edges: ${metricEdges.length} metric relationships, ${measures.length} MEASURES, ${entityEdges.length} entity/concept relationships, ${belongs.length} BELONGS_TO, ${sourcedFrom.length} SOURCED_FROM`, "//", "// Relationship types (metric signal): DRIVES, REDUCES, PREDICTS, COMPONENT_OF, MEASURES", "// Relationship types (entity): " + Object.values(ont.entityRelationships?.edgeTypeClassification ?? {}).flat().join(", "), "// Relationship types (data lineage): BELONGS_TO (Field → Dataset), SOURCED_FROM (Field → SourceCategory)", "//", "// Import: paste into Neo4j Browser or pipe to cypher-shell", "// ═══════════════════════════════════════════════════════════════", "", "// Clean slate (optional, uncomment to clear existing data)", "// MATCH (n) DETACH DELETE n;", "");
186
186
  L.push(`// ─── Metric Nodes (${metricNodes.length}) ─────────────────────────`);
187
187
  for (const n of metricNodes) {
188
188
  const m = byId.get(n.id) ?? data.metrics.find((x) => metricFormulaId(data, x) === n.id);
package/build/index.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { loadStandard, loadBundled, loadRemote, findDefinition, metricNodeId, getRelationships, resolveNodeResult, suggestNodes, search, metricFormulaId, isMetricStub } from "./loader.js";
6
6
  import { buildContext, inferDrivers, findPaths, coverageGap, noDriversMessage, } from "./traversal.js";
7
- import { SITE, metricUrl, definitionUrl, resolveDocLink } from "./urls.js";
7
+ import { SITE, metricUrl, definitionUrl, nodeUrl, resolveDocLink } from "./urls.js";
8
8
  import { formatCanonicalFormula, resolveMetric, suggestMatches, formatAmbiguous, fillTemplate, validateAdapter, formatDataRequirement, breakdownTemplate, breakdownRequirement, chooseDialect } from "./tools.js";
9
9
  import { DIALECTS } from "./dialect.js";
10
10
  import { readFileSync } from "node:fs";
@@ -28,7 +28,7 @@ catch {
28
28
  }
29
29
  }
30
30
  }
31
- // Background version check — non-blocking, stderr only
31
+ // Background version check: non-blocking, stderr only
32
32
  const VERSION_URL = "https://gaspwiki.com/api/standard-version.json";
33
33
  (async () => {
34
34
  try {
@@ -49,7 +49,7 @@ const VERSION_URL = "https://gaspwiki.com/api/standard-version.json";
49
49
  }
50
50
  }
51
51
  catch {
52
- // Offline or gaspwiki.com unreachable — silently ignore
52
+ // Offline or gaspwiki.com unreachable: silently ignore
53
53
  }
54
54
  })();
55
55
  const PKG_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
@@ -122,8 +122,14 @@ function formatDefinition(def) {
122
122
  parts.push(`**Used in:** ${def.usedIn.join(", ")}`);
123
123
  return parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
124
124
  }
125
+ /** The reply when a name given to a metric-only tool is a glossary term or matches nothing. */
126
+ function notAMetric(name, resolved) {
127
+ if (resolved.kind === "definition")
128
+ return `"${name}" is a glossary term, not a metric: use lookup_definition (${definitionUrl(resolved.definition)}).`;
129
+ return `No metric is named "${name}".${suggestMatches(data, name)}`;
130
+ }
125
131
  // --- Tool 1: lookup_metric ---
126
- server.tool("lookup_metric", "Look up a GASP Standard metric by exact name, abbreviation, alias or formula id. Returns definition, formula, benchmarks, time period, department and page URL. When a name belongs to several departments' metrics (e.g. 'Escalation Rate') it lists each with its department, formula id and URL; qualify by department ('Support Escalation Rate') or pass the formula id. Falls back to glossary terms (e.g. 'Signal Type', 'Metric Owner') when no metric matches; use lookup_definition to ask for a term directly.", { name: z.string().describe("Metric name, abbreviation or formula id (e.g. 'MRR', 'Net Revenue Retention', 'CAC Payback', 'support_escalation_rate')") }, async ({ name }) => {
132
+ server.tool("lookup_metric", "The full record of one GASP Standard metric: department, category, signal type, page URL, definition, the formulas as written, the canonical GASP grammar formula (unit, forms, variants, breakdowns, fields and datasets), benchmarks, validation rules, common mistakes, sources, aliases, the entities it measures, operating and market forms, any adjusted variant and the recommended sensitivity tier and access scope. The name must match exactly, ignoring case: formula id, graph node id, full name, name without its abbreviation, abbreviation, graph label, alias or slug. A name several departments use (e.g. 'Escalation Rate') returns each candidate with department, formula id and URL; prefix the department ('Support Escalation Rate') or pass the formula id. A glossary term (e.g. 'Signal Type') returns its definition and a miss returns close names to retry with. Use get_formula for the formula alone and search when you do not know the name.", { name: z.string().describe("Exact metric name, abbreviation, alias, slug or formula id, case-insensitive (e.g. 'MRR', 'Net Revenue Retention', 'CAC Payback', 'support_escalation_rate'). Prefix the department to pick between metrics that share a name.") }, async ({ name }) => {
127
133
  const resolved = resolveMetric(data, name);
128
134
  if (resolved.kind === "ambiguous") {
129
135
  return { content: [{ type: "text", text: formatAmbiguous(data, name, resolved.candidates) }] };
@@ -254,15 +260,15 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by exact name, abbr
254
260
  return { content: [{ type: "text", text }] };
255
261
  });
256
262
  // --- Tool 2: list_metrics ---
257
- server.tool("list_metrics", "List all GASP Standard metrics, optionally filtered by department and/or signal type. Returns metric names grouped by department and category. The Core tier is the 'Core' department. An unknown department returns the department list.", {
263
+ server.tool("list_metrics", "List GASP Standard metric names grouped by department and category, with the total, optionally filtered by department, signal type or both. Names only: use lookup_metric or get_formula for a metric's details. A metric cross-referenced from another department is listed under both (NRR under Core and Customer Success). The Core tier is the 'Core' department. An unknown department returns close names and the department list.", {
258
264
  department: z
259
265
  .string()
260
266
  .optional()
261
- .describe("Filter by exact department name (e.g. 'sales', 'customer success', 'core'). Case-insensitive. Omit to list all."),
267
+ .describe("Department name, case-insensitive, with spaces or hyphens (e.g. 'Sales', 'customer success', 'customer-success', 'Core'). Omit to list every department."),
262
268
  signalType: z
263
269
  .enum(["outcome", "leading", "efficiency", "operational"])
264
270
  .optional()
265
- .describe("Filter by signal type. Returns metrics where primary or secondary type matches."),
271
+ .describe("outcome, leading, efficiency or operational. Matches a metric's primary or secondary signal type."),
266
272
  }, async ({ department, signalType }) => {
267
273
  let filtered = data.metrics;
268
274
  if (department) {
@@ -299,7 +305,9 @@ server.tool("list_metrics", "List all GASP Standard metrics, optionally filtered
299
305
  }
300
306
  dept.get(m.category).push(m.name);
301
307
  }
302
- const parts = [`# GASP Standard Metrics (${filtered.length} total)\n`];
308
+ // A metric cross-referenced from another department is listed under both but counted once.
309
+ const distinct = new Set(filtered.map((m) => metricFormulaId(data, m))).size;
310
+ const parts = [`# GASP Standard Metrics (${distinct} total${distinct < filtered.length ? `, ${filtered.length} listings` : ""})\n`];
303
311
  for (const [dept, categories] of grouped) {
304
312
  parts.push(`## ${dept}`);
305
313
  for (const [cat, names] of categories) {
@@ -314,13 +322,13 @@ server.tool("list_metrics", "List all GASP Standard metrics, optionally filtered
314
322
  return { content: [{ type: "text", text: parts.join("\n") }] };
315
323
  });
316
324
  // --- Tool 3: get_formula ---
317
- server.tool("get_formula", "Get the canonical formula for a GASP Standard metric: the machine-readable GASP grammar formula with its unit, operating and market forms and named variants, the metrics, fields and datasets it and each form read, the enum filters each form applies (so forms reading the same fields show where they differ), plus the formula as written in the standard.", { metric: z.string().describe("Metric name, abbreviation or formula id (e.g. 'NRR', 'LTV', 'CAC Payback'). A name several departments use returns the candidates to choose from.") }, async ({ metric: name }) => {
325
+ server.tool("get_formula", "The canonical formula of one GASP Standard metric in the GASP grammar, with its unit, operating (O) and market (M) forms, named variants, breakdowns (one row per value of a field or bucket; total() reads the whole population), notes, the metrics it reads with role and polarity, the fields and datasets the formula and each form read, the enum filters each form applies (so forms reading the same fields show where they differ) and the formula as written in the standard. A metric that cannot be computed as one formula gives the reason. Names resolve as in lookup_metric. Use get_data_requirements for field types and example SQL and generate_query for SQL against your own tables.", { metric: z.string().describe("Exact metric name, abbreviation, alias or formula id (e.g. 'NRR', 'LTV', 'CAC Payback', 'aging_buckets'). A name several departments use returns the candidates to choose from.") }, async ({ metric: name }) => {
318
326
  const resolved = resolveMetric(data, name);
319
327
  if (resolved.kind === "ambiguous") {
320
328
  return { content: [{ type: "text", text: formatAmbiguous(data, name, resolved.candidates) }] };
321
329
  }
322
330
  if (resolved.kind !== "metric") {
323
- return { content: [{ type: "text", text: `No metric is named "${name}".${suggestMatches(data, name)}` }] };
331
+ return { content: [{ type: "text", text: notAMetric(name, resolved) }] };
324
332
  }
325
333
  const metric = resolved.metric;
326
334
  const parts = [`# ${metric.name}: Formula`, `**URL:** ${metricUrl(metric)}`, ""];
@@ -333,7 +341,7 @@ server.tool("get_formula", "Get the canonical formula for a GASP Standard metric
333
341
  parts.push("\n## As written in the standard");
334
342
  for (let i = 0; i < metric.formulas.length; i++) {
335
343
  if (metric.formulas.length > 1)
336
- parts.push(`**Variant ${i + 1}:**`);
344
+ parts.push(`**Formula ${i + 1}:**`);
337
345
  parts.push("```\n" + metric.formulas[i] + "\n```");
338
346
  }
339
347
  }
@@ -366,7 +374,7 @@ function formatValidation(d, metric) {
366
374
  return bits.join("; ");
367
375
  }
368
376
  // --- Tool 4: get_relationships ---
369
- server.tool("get_relationships", "Get the upstream (what drives it) and downstream (what it drives) relationships for a metric, entity, or concept from the GASP relationship graph. Names resolve exactly as in lookup_metric (an ambiguous metric name lists the candidates). For an entity it also lists every metric whose formula reads the entity's datasets, with department and URL. Relationship types: metric signal (DRIVES, REDUCES, PREDICTS, COMPONENT_OF, MEASURES), entity lifecycle (CONVERTS_TO, PARENT_OF, BELONGS_TO), entity ownership (OWNS, GRANTS, PRICED_BY), entity interactions (MANAGES, SERVES, ENGAGES_VIA, SUBMITS, REPRESENTED_BY, SOURCES), entity financial flows (GENERATES, PRODUCES, CONTRIBUTES_TO, INCURS).", { metric: z.string().describe("Metric name or ID (e.g. 'arr', 'NRR'), entity (e.g. 'customer', 'subscription'), or concept (e.g. 'revenue', 'bookings')") }, async ({ metric: name }) => {
377
+ server.tool("get_relationships", "One node of the GASP relationship graph (a metric, entity or concept) with its description, category and level and its direct edges, each neighbour with its edge type and description: upstream (edges into it), the metrics that measure it, downstream (edges out of it) and, for a metric, the entities it measures. For an entity it also lists every metric whose formula reads the entity's datasets, with department and URL. Edge types: drives, reduces, predicts, component and measures (metric signal); converts_to, parent_of and belongs_to (lifecycle); owns, grants and priced_by (ownership); manages, serves, engages_via, submits, represented_by and sources (interaction); generates, produces, contributes_to and incurs (financial flow). Metric names resolve as in lookup_metric; a metric outside the graph says so and a miss lists every node. Use infer_drivers or explain_path to walk more than one hop.", { metric: z.string().describe("Metric name, abbreviation or graph id (e.g. 'NRR', 'arr'), entity (e.g. 'customer', 'subscription') or concept (e.g. 'revenue', 'bookings'). Entity and concept names win over a metric with the same name.") }, async ({ metric: name }) => {
370
378
  const { node, upstream, downstream, measuredBy: readers, ambiguous, outsideGraph } = getRelationships(data, name);
371
379
  if (ambiguous)
372
380
  return { content: [{ type: "text", text: formatAmbiguous(data, name, ambiguous) }] };
@@ -384,47 +392,46 @@ server.tool("get_relationships", "Get the upstream (what drives it) and downstre
384
392
  ],
385
393
  };
386
394
  }
395
+ const kind = node.nodeType ?? "metric";
387
396
  const parts = [
388
- `# ${node.label} — Relationships\n`,
397
+ `# ${node.label}: relationships\n`,
389
398
  `**Description:** ${node.description}`,
390
399
  `**Category:** ${node.category}`,
391
- `**Level:** ${node.level}\n`,
400
+ `**Level:** ${node.level}`,
401
+ `**URL:** ${nodeUrl(node)}\n`,
392
402
  ];
403
+ const neighbour = (r) => `- **${r.node.label}** (${r.type}): ${r.node.description} ${nodeUrl(r.node)}`;
393
404
  const upDrivers = upstream.filter((r) => r.type !== "measures");
394
405
  const measuredBy = upstream.filter((r) => r.type === "measures");
395
406
  const downDriven = downstream.filter((r) => r.type !== "measures");
396
407
  const measures = downstream.filter((r) => r.type === "measures");
397
408
  if (upDrivers.length > 0) {
398
- parts.push("## Upstream (what drives this metric)");
399
- for (const r of upDrivers) {
400
- parts.push(`- **${r.node.label}** (${r.type}) — ${r.node.description}`);
401
- }
409
+ parts.push(kind === "metric" ? "## Upstream (what drives this metric)" : `## Upstream (edges into this ${kind})`);
410
+ for (const r of upDrivers)
411
+ parts.push(neighbour(r));
402
412
  parts.push("");
403
413
  }
404
414
  else {
405
- parts.push("## Upstream\nNo upstream drivers (this is a top-level metric).\n");
415
+ parts.push(kind === "metric" ? "## Upstream\nNo upstream drivers (this is a top-level metric).\n" : `## Upstream\nNo edges lead into this ${kind}.\n`);
406
416
  }
407
417
  if (measuredBy.length > 0) {
408
418
  parts.push("## Measured by (metrics that measure this)");
409
- for (const r of measuredBy) {
410
- parts.push(`- **${r.node.label}** (${r.type}) — ${r.node.description}`);
411
- }
419
+ for (const r of measuredBy)
420
+ parts.push(neighbour(r));
412
421
  parts.push("");
413
422
  }
414
423
  if (downDriven.length > 0) {
415
- parts.push("## Downstream (what this metric drives)");
416
- for (const r of downDriven) {
417
- parts.push(`- **${r.node.label}** (${r.type}) — ${r.node.description}`);
418
- }
424
+ parts.push(kind === "metric" ? "## Downstream (what this metric drives)" : `## Downstream (edges out of this ${kind})`);
425
+ for (const r of downDriven)
426
+ parts.push(neighbour(r));
419
427
  }
420
428
  else {
421
- parts.push("## Downstream\nNo downstream metrics (this is a leaf metric).");
429
+ parts.push(kind === "metric" ? "## Downstream\nNo downstream metrics (this is a leaf metric)." : `## Downstream\nNo edges lead out of this ${kind}.`);
422
430
  }
423
431
  if (measures.length > 0) {
424
432
  parts.push("\n## Measures (what this metric measures)");
425
- for (const r of measures) {
426
- parts.push(`- **${r.node.label}** (${r.type}) — ${r.node.description}`);
427
- }
433
+ for (const r of measures)
434
+ parts.push(neighbour(r));
428
435
  }
429
436
  if (readers.length > 0) {
430
437
  parts.push(`\n## Measured by, from the ontology (${readers.length} metrics reading this entity's datasets)`);
@@ -434,17 +441,17 @@ server.tool("get_relationships", "Get the upstream (what drives it) and downstre
434
441
  return { content: [{ type: "text", text: parts.join("\n") }] };
435
442
  });
436
443
  // --- Tool 5: get_data_requirements ---
437
- server.tool("get_data_requirements", "Get the data requirements for implementing a GASP Standard metric or department dashboard: each field the canonical formula reads with its type and the GASP dataset it is read from, whether the metric needs a staged dataset (cohort or prior-period comparison), reporting grain, source system, page URL and example SQL. The metric resolves exactly as in lookup_metric (name, abbreviation, alias or formula id); a name several departments use lists the candidates unless department picks one. Department is an exact name, case-insensitive. Pass breakdown with the name of a breakdown the metric declares (get_formula lists them, e.g. 'by_plan' on MRR) to get that breakdown's requirement: its fields include the dimension and the example SQL returns one row per value. Pass dialect (postgres, snowflake, bigquery, databricks; postgres also covers Redshift) to get the example SQL in that warehouse's syntax.", {
444
+ server.tool("get_data_requirements", "What a warehouse needs to compute GASP Standard metrics: per metric, a row for each field and dataset pair the canonical formula reads with its type, whether it is staged (a cohort or prior-period comparison), source system, grain, frequency, page URL and example SQL compiled from the formula. Pass metric for one metric (resolved as in lookup_metric), department for every metric in it, both to pick between metrics that share a name and neither for every metric with a formula. breakdown names a breakdown the metric declares (get_formula lists them, e.g. 'by_plan' on MRR): the fields then include the dimension and the SQL returns one row per value. dialect (postgres, snowflake, bigquery or databricks; postgres is the default and also covers Redshift) sets the example SQL's syntax. A metric with no formula gives the reason. Use generate_query to fill the SQL from your adapter.", {
438
445
  metric: z
439
446
  .string()
440
447
  .optional()
441
- .describe("Metric name, abbreviation or formula id (e.g. 'ARR', 'Pipeline Coverage Ratio', 'CSAT')"),
448
+ .describe("Exact metric name, abbreviation, alias or formula id (e.g. 'ARR', 'Pipeline Coverage Ratio', 'CSAT')"),
442
449
  department: z
443
450
  .string()
444
451
  .optional()
445
- .describe("Exact department name (e.g. 'Sales', 'Engineering', 'Customer Success'). Alone it lists the department's requirements; with metric it picks between metrics that share a name."),
446
- breakdown: z.string().optional().describe("Name of a named breakdown the metric declares (e.g. 'by_plan' on MRR); needs metric. The requirement then covers the breakdown's query, one row per value."),
447
- dialect: z.enum(DIALECTS).optional().describe("SQL dialect of the example SQL: postgres (default, also Redshift), snowflake, bigquery or databricks; needs metric."),
452
+ .describe("Department name, case-insensitive (e.g. 'Sales', 'Engineering', 'customer success'). Alone it returns every requirement in the department; with metric it picks between metrics that share a name."),
453
+ breakdown: z.string().optional().describe("Name of a breakdown the metric declares (e.g. 'by_plan' or 'by_segment' on MRR); needs metric. The requirement then covers the breakdown's query, one row per value. An unknown name lists the declared ones."),
454
+ dialect: z.enum(DIALECTS).optional().describe("Dialect of the example SQL: postgres (default, also Redshift), snowflake, bigquery or databricks. Any but postgres needs metric."),
448
455
  }, async ({ metric, department, breakdown, dialect }) => {
449
456
  const all = data.dataRequirements || [];
450
457
  const text = (t) => ({ content: [{ type: "text", text: t }] });
@@ -466,7 +473,7 @@ server.tool("get_data_requirements", "Get the data requirements for implementing
466
473
  if (resolved.kind === "ambiguous")
467
474
  return text(formatAmbiguous(data, metric, resolved.candidates));
468
475
  if (resolved.kind !== "metric")
469
- return text(`No metric is named "${metric}".${suggestMatches(data, metric)}`);
476
+ return text(notAMetric(metric, resolved));
470
477
  const id = metricFormulaId(data, resolved.metric);
471
478
  filtered = all.filter((dr) => dr.formulaId === id);
472
479
  if (filtered.length === 0) {
@@ -498,7 +505,7 @@ server.tool("get_data_requirements", "Get the data requirements for implementing
498
505
  pages.set(id, metricUrl(m));
499
506
  }
500
507
  const parts = [
501
- `# Data Requirements (${filtered.length} entries)\n`,
508
+ `# Data Requirements (${filtered.length} ${filtered.length === 1 ? "entry" : "entries"}${metric && dialect ? `, example SQL in ${dialect}` : ""})\n`,
502
509
  ];
503
510
  // Group by department
504
511
  const grouped = new Map();
@@ -515,14 +522,14 @@ server.tool("get_data_requirements", "Get the data requirements for implementing
515
522
  return text(parts.join("\n"));
516
523
  });
517
524
  // --- Tool 6: search ---
518
- server.tool("search", "Search across all GASP Standard metrics and glossary definitions by whole words. Returns up to 10 results, one per metric or term, each with what matched and its URL: name and alias hits rank above definition hits, which rank above formula and field hits. When nothing matches (e.g. a misspelling) it returns the closest names labelled 'Did you mean'.", { query: z.string().describe("Search query (e.g. 'churn', 'retention', 'pipeline coverage')") }, async ({ query: q }) => {
525
+ server.tool("search", "Find GASP Standard metrics and glossary terms when you do not know the exact name. The query matches as a phrase of whole words, the last word allowing a plural or verb ending ('churn' finds 'churned'), against metric names, aliases, definitions, formulas, the fields each formula reads, categories and departments, plus glossary terms, definitions and sections. Returns up to 10 results, one per metric or term, each with its type, department, what matched, a snippet and its URL. Names, aliases and terms rank first, then words within them, then definitions, then formulas and fields. When nothing matches (e.g. a misspelling) it returns up to 6 close names labelled 'Did you mean'. Pass a result's name to lookup_metric or lookup_definition.", { query: z.string().describe("Words to find (e.g. 'churn', 'retention', 'pipeline coverage', 'opportunity_stage')") }, async ({ query: q }) => {
519
526
  const results = search(data, q, 10);
520
527
  if (results.length === 0) {
521
528
  return {
522
529
  content: [
523
530
  {
524
531
  type: "text",
525
- text: `No results found for "${q}".`,
532
+ text: `No results found for "${q}". Try fewer or broader words, or list_metrics to browse by department.`,
526
533
  },
527
534
  ],
528
535
  };
@@ -539,25 +546,25 @@ server.tool("search", "Search across all GASP Standard metrics and glossary defi
539
546
  return { content: [{ type: "text", text: parts.join("\n") }] };
540
547
  });
541
548
  // --- Tool 7: generate_query ---
542
- server.tool("generate_query", "Generate SQL for a GASP metric from your adapter. The query is compiled from the metric's canonical formula: one scalar subquery per aggregate, each on the warehouse table you mapped to that GASP dataset, with :period_start and :period_end parameters. Fields you have not mapped stay as {{field@dataset}} placeholders, where the tag names the GASP dataset the column must come from. Pass breakdown with the name of a breakdown the metric declares (get_formula lists them, e.g. 'by_plan' on MRR) to get one row per value of its field. The SQL dialect (postgres, snowflake, bigquery, databricks; postgres also covers Redshift) is the dialect argument, else the adapter's warehouse when it names one of them, else postgres; the output says which it used.", {
543
- metric: z.string().describe("Metric name or abbreviation (e.g. 'NRR', 'CAC', 'Pipeline Value', 'ARR')"),
544
- adapter: z.string().describe("Your GASP adapter JSON string (see validate_adapter)"),
545
- breakdown: z.string().optional().describe("Name of a named breakdown the metric declares (e.g. 'by_plan' on MRR). The query returns one row per value of the breakdown's field."),
546
- dialect: z.enum(DIALECTS).optional().describe("SQL dialect: postgres (also Redshift), snowflake, bigquery or databricks. Defaults to the adapter's warehouse when it names one of them, else postgres."),
549
+ server.tool("generate_query", "SQL for one GASP Standard metric against your warehouse, compiled from its canonical formula and filled from your adapter: one scalar subquery per aggregate, each reading the table mapped to its GASP dataset, with :period_start and :period_end date parameters (both inclusive). Pairs you have not mapped stay as {{field@dataset}} placeholders, where the tag names the GASP dataset the column must come from; the output lists them with any dataset that has no table and any mapping conflict, plus the URL, dialect, datasets, grain and the mapping it used. breakdown names a breakdown the metric declares (get_formula lists them, e.g. 'by_plan' on MRR) and returns one row per value. The dialect (postgres, snowflake, bigquery or databricks; postgres also covers Redshift) is the dialect argument, else the adapter's warehouse when it names one of them, else postgres. Check the adapter with validate_adapter first.", {
550
+ metric: z.string().describe("Exact metric name, abbreviation, alias or formula id (e.g. 'NRR', 'CAC', 'Pipeline Value', 'ARR'), resolved as in lookup_metric"),
551
+ adapter: z.string().describe("Your GASP adapter as a JSON string (schema: https://gaspwiki.com/schemas/gasp-adapter-v1.json; check it with validate_adapter)"),
552
+ breakdown: z.string().optional().describe("Name of a breakdown the metric declares (e.g. 'by_plan' on MRR). The query returns one row per value of the breakdown's field or bucket. An unknown name lists the declared ones."),
553
+ dialect: z.enum(DIALECTS).optional().describe("SQL dialect: postgres (also Redshift), snowflake, bigquery or databricks. Omit to use the adapter's warehouse when it names one of them (postgresql and redshift read as postgres), else postgres."),
547
554
  }, async ({ metric: metricName, adapter: adapterJson, breakdown, dialect: requestedDialect }) => {
548
555
  const resolved = resolveMetric(data, metricName);
549
556
  if (resolved.kind === "ambiguous") {
550
557
  return { content: [{ type: "text", text: formatAmbiguous(data, metricName, resolved.candidates) }] };
551
558
  }
552
559
  if (resolved.kind !== "metric") {
553
- return { content: [{ type: "text", text: `No metric is named "${metricName}".${suggestMatches(data, metricName)}` }] };
560
+ return { content: [{ type: "text", text: notAMetric(metricName, resolved) }] };
554
561
  }
555
562
  let adapter;
556
563
  try {
557
564
  adapter = JSON.parse(adapterJson);
558
565
  }
559
566
  catch {
560
- return { content: [{ type: "text", text: "Invalid adapter JSON." }] };
567
+ return { content: [{ type: "text", text: "Invalid adapter JSON: pass the adapter as a JSON string." }] };
561
568
  }
562
569
  if (!Array.isArray(adapter?.mappings)) {
563
570
  return { content: [{ type: "text", text: 'Adapter must have a "mappings" array. Template: https://gaspwiki.com/downloads/gasp-adapter-template.json' }] };
@@ -591,10 +598,10 @@ server.tool("generate_query", "Generate SQL for a GASP metric from your adapter.
591
598
  if (r.missingFields.length)
592
599
  parts.push(`**Unmapped fields (left as placeholders):** \`${r.missingFields.join("\`, \`")}\``);
593
600
  if (r.missingTables.length)
594
- parts.push(`**Datasets with no table:** ${r.missingTables.join(", ")}. Add them under \"datasets\" in the adapter or map a field from each.`);
601
+ parts.push(`**Datasets with no table:** ${r.missingTables.join(", ")}. Name a table for each under \"datasets\" in the adapter, or map one of its fields with a schema.table column reference.`);
595
602
  for (const c of r.conflicts)
596
603
  parts.push(`**Conflict:** ${c}`);
597
- parts.push("", "Parameters: `:period_start` and `:period_end` are dates bounding the reporting period.", "", "```sql\n" + r.sql + "\n```");
604
+ parts.push("", "Parameters: `:period_start` and `:period_end` are the first and last dates of the reporting period, both inclusive.", "", "```sql\n" + r.sql + "\n```");
598
605
  if (r.mapped.length) {
599
606
  parts.push("\n**Field mapping used:**");
600
607
  for (const m of r.mapped)
@@ -603,7 +610,7 @@ server.tool("generate_query", "Generate SQL for a GASP metric from your adapter.
603
610
  return { content: [{ type: "text", text: parts.join("\n") }] };
604
611
  });
605
612
  // --- Tool 8: validate_adapter ---
606
- server.tool("validate_adapter", "Validate a GASP adapter against the standard. First applies the adapter JSON Schema (https://gaspwiki.com/schemas/gasp-adapter-v1.json, version 1.1) and lists each violation with its JSON path: missing version or mappings, unknown version values, keys the schema does not allow and values of the wrong type. Then, whether or not the schema passed, it reports gaspField values not in the ontology, unknown datasets, fields mapped to a dataset that does not carry them, bare column names on fields that live on several datasets, datasets whose fields point at different tables, scores every metric against the field and dataset pairs its canonical formula reads (fully covered, partially covered, not covered), lists enum fields mapped without a transform, covered metrics with no declared owner and owners naming unknown metrics, and ranks the next pairs to map. Coverage needs a mappings array; without one only the schema result is returned.", { adapter: z.string().describe("The adapter JSON as a string. Template: https://gaspwiki.com/downloads/gasp-adapter-template.json") }, async ({ adapter: adapterJson }) => {
613
+ server.tool("validate_adapter", "Check a GASP adapter against the standard and score its coverage. It first applies the adapter JSON Schema (https://gaspwiki.com/schemas/gasp-adapter-v1.json, versions 1.0 and 1.1) and lists each violation with its JSON path: missing version or mappings, unknown version values, keys the schema does not allow and values of the wrong type. Then, whether or not the schema passed, it reports gaspField values not in the ontology, unknown datasets, fields mapped to a dataset that does not carry them, bare column names on fields that live on several datasets, datasets whose fields point at different tables and pairs mapped twice. It counts the field and dataset pairs mapped and the datasets with a table, scores every metric with a formula against the pairs its compiled SQL reads (fully covered, partially covered with the missing pairs, not covered), lists enum fields mapped without a transform, covered metrics with no declared owner and owners naming unknown metrics. It also ranks up to 15 unmapped pairs by how many partially covered metrics need them. Without a mappings array only the schema result is returned. Run it before generate_query.", { adapter: z.string().describe("The adapter JSON as a string. Template: https://gaspwiki.com/downloads/gasp-adapter-template.json") }, async ({ adapter: adapterJson }) => {
607
614
  let adapter;
608
615
  try {
609
616
  adapter = JSON.parse(adapterJson);
@@ -615,10 +622,10 @@ server.tool("validate_adapter", "Validate a GASP adapter against the standard. F
615
622
  return { content: [{ type: "text", text }] };
616
623
  });
617
624
  // --- Tool 9: infer_drivers ---
618
- server.tool("infer_drivers", "Diagnose what could explain an observed metric movement. Walks the GASP relationship graph in reverse and ranks upstream metrics whose movement (in the inferred direction) is consistent with the observation. Returns ranked candidates with full path and per-step edge classifications. Structural reasoning, not numeric forecasting — scores are attenuation products, not magnitude estimates. Compose with explain_path to scrutinise a chain or coverage_gap to check whether a candidate is being measured. For counterfactual 'what if' simulation, use the TWIN Modifier at https://gaspwiki.com/v1/twin-modifier instead.", {
619
- metric: z.string().describe("Metric name, abbreviation, or graph id (e.g. 'ARR', 'NRR', 'arr')"),
620
- direction: z.enum(["up", "down"]).describe("Observed direction of movement"),
621
- maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum traversal depth (default 6)"),
625
+ server.tool("infer_drivers", "Rank the upstream metrics that could explain an observed movement in a metric. Walks the GASP relationship graph backwards from the metric up to maxDepth edges and returns up to 25 candidates, each with the direction it would have moved, a score and its path to the metric. Direction flips across a reduces edge and across an input with negative polarity (a denominator or subtracted term), marked ↓ in the path. A baseline input (read at period start, as NRR reads MRR) is listed as a candidate but not walked through, since its own drivers cannot move the metric within the period. Scores are products of edge attenuations: they rank structural links and say nothing about magnitude. Works on metric nodes only. Follow up with explain_path for the chain behind a candidate or coverage_gap to check you track it; for 'what if' simulation use the TWIN Modifier at https://gaspwiki.com/v1/twin-modifier.", {
626
+ metric: z.string().describe("Metric name, abbreviation or graph id (e.g. 'ARR', 'Net Revenue Retention', 'nrr'), resolved as in lookup_metric. It must be a metric in the relationship graph."),
627
+ direction: z.enum(["up", "down"]).describe("Observed movement of the metric: up or down"),
628
+ maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum number of edges to walk back, 1 to 8 (default 6)"),
622
629
  }, async ({ metric: name, direction: dirStr, maxDepth }) => {
623
630
  const { node, text: missing } = resolveNode(name);
624
631
  if (!node) {
@@ -626,7 +633,7 @@ server.tool("infer_drivers", "Diagnose what could explain an observed metric mov
626
633
  }
627
634
  if (!traversalCtx.metricNodeIds.has(node.id)) {
628
635
  return {
629
- content: [{ type: "text", text: `"${node.label}" is not a metric node — infer_drivers operates on metrics only. Try get_relationships for entity/concept nodes.` }],
636
+ content: [{ type: "text", text: `"${node.label}" is ${node.nodeType === "concept" ? "a concept" : "an entity"}, not a metric. infer_drivers works on metric nodes only; use get_relationships for an entity or concept.` }],
630
637
  };
631
638
  }
632
639
  const observedDirection = dirStr === "up" ? 1 : -1;
@@ -638,7 +645,7 @@ server.tool("infer_drivers", "Diagnose what could explain an observed metric mov
638
645
  }
639
646
  const arrow = (d) => (d === 1 ? "↑" : "↓");
640
647
  const parts = [
641
- `# ${node.label} ${arrow(observedDirection)} — Candidate drivers (${candidates.length})\n`,
648
+ `# ${node.label} ${arrow(observedDirection)}: candidate drivers (${candidates.length})\n`,
642
649
  `_Structural reasoning, not numeric forecasting. Scores are attenuation products (0–1) along the chain; they rank candidates but do not estimate magnitudes._\n`,
643
650
  `| # | Driver | Implied direction | Score | Path |`,
644
651
  `|---|--------|------------------|-------|------|`,
@@ -653,10 +660,10 @@ server.tool("infer_drivers", "Diagnose what could explain an observed metric mov
653
660
  return { content: [{ type: "text", text: parts.join("\n") }] };
654
661
  });
655
662
  // --- Tool 10: explain_path ---
656
- server.tool("explain_path", "Explain how one metric reaches another by enumerating edge chains in the GASP relationship graph. Each step carries the canonical edge type and a classification bucket (metric_signal / structural / lifecycle / behavioral / assignment / generative). Returns up to 5 distinct paths sorted by length then attenuation. Empty if disconnected in the forward direction. Compose with infer_drivers to scrutinise a candidate chain.", {
657
- from: z.string().describe("Source metric or node (name, abbreviation, or id)"),
658
- to: z.string().describe("Target metric or node"),
659
- maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum chain length (default 6)"),
663
+ server.tool("explain_path", "How a movement in one graph node reaches another: up to 5 chains of edges followed forward from `from` to `to`, shortest first, then by highest score. Each step gives the edge type, its classification (metric_signal, structural, lifecycle, behavioral, assignment or generative), its attenuation and whether it inverts direction; each path gives its score and the direction the target moves when the source rises. A baseline edge (MRR into NRR) can only start a path. Names resolve as in get_relationships, so entities and concepts work too. No path means no forward chain within maxDepth: try the reverse. Use it on a candidate from infer_drivers.", {
664
+ from: z.string().describe("Starting node: a metric name, abbreviation or graph id, or an entity or concept (e.g. 'Health Score', 'customer')"),
665
+ to: z.string().describe("Target node, in the same forms as from (e.g. 'NRR')"),
666
+ maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum chain length in edges, 1 to 8 (default 6)"),
660
667
  }, async ({ from, to, maxDepth }) => {
661
668
  const fromHit = resolveNode(from);
662
669
  const toHit = resolveNode(to);
@@ -670,31 +677,32 @@ server.tool("explain_path", "Explain how one metric reaches another by enumerati
670
677
  return {
671
678
  content: [{
672
679
  type: "text",
673
- text: `# ${fromNode.label} → ${toNode.label}\n\nNo forward path found within depth ${maxDepth ?? 6}. They may be disconnected in this direction (try the reverse, or use coverage_gap to inspect connectivity).`,
680
+ text: `# ${fromNode.label} → ${toNode.label}\n\nNo forward path found within depth ${maxDepth ?? 6}. They may be connected only in the other direction (swap from and to) or the chain may be longer than maxDepth.`,
674
681
  }],
675
682
  };
676
683
  }
677
684
  const parts = [
678
- `# ${fromNode.label} → ${toNode.label} — ${paths.length} path${paths.length === 1 ? "" : "s"}\n`,
685
+ `# ${fromNode.label} → ${toNode.label}: ${paths.length} path${paths.length === 1 ? "" : "s"}\n`,
686
+ `_End direction: how ${toNode.label} moves when ${fromNode.label} rises along that path. Scores are products of edge attenuations and say nothing about magnitude._\n`,
679
687
  ];
680
688
  for (let i = 0; i < paths.length; i++) {
681
689
  const p = paths[i];
682
- parts.push(`## Path ${i + 1} — ${p.path.length} step${p.path.length === 1 ? "" : "s"}, score ${p.score.toFixed(3)}, end direction ${p.direction === 1 ? "↑" : "↓"}`);
690
+ parts.push(`## Path ${i + 1}: ${p.path.length} step${p.path.length === 1 ? "" : "s"}, score ${p.score.toFixed(3)}, end direction ${p.direction === 1 ? "↑" : "↓"}`);
683
691
  parts.push(`Classifications: ${renderClassifications(p.path)}\n`);
684
692
  for (let j = 0; j < p.path.length; j++) {
685
693
  const s = p.path[j];
686
694
  const flip = s.directionFlip ? " (↓ inverts direction)" : "";
687
695
  const fromLabel = traversalCtx.nodeById.get(s.from)?.label ?? s.from;
688
696
  const toLabel = traversalCtx.nodeById.get(s.to)?.label ?? s.to;
689
- parts.push(`${j + 1}. **${fromLabel}** → **${toLabel}** — \`${s.edgeType}\` (${s.classification}, attenuation ${s.attenuation.toFixed(2)})${flip}`);
697
+ parts.push(`${j + 1}. **${fromLabel}** → **${toLabel}**: \`${s.edgeType}\` (${s.classification}, attenuation ${s.attenuation.toFixed(2)})${flip}`);
690
698
  }
691
699
  parts.push("");
692
700
  }
693
701
  return { content: [{ type: "text", text: parts.join("\n") }] };
694
702
  });
695
703
  // --- Tool 11: coverage_gap ---
696
- server.tool("coverage_gap", "Audit a metric set against the GASP relationship graph. Returns three groups: upstream_missing (one-hop drivers not in the set), downstream_missing (one-hop downstream metrics not in the set), and disconnected (in-graph metrics with no path of any length to/from the set). Operates on metric nodes only — entities and concepts are ignored. Use to identify gaps before instrumenting a dashboard, then compose with infer_drivers or explain_path to investigate specific candidates.", {
697
- metrics: z.array(z.string()).min(1).describe("Metric names, abbreviations, or graph ids (e.g. ['ARR', 'NRR', 'CAC'])"),
704
+ server.tool("coverage_gap", "Audit a set of tracked metrics against the GASP relationship graph. Returns three lists: upstream missing (metrics with a direct edge into the set that the set leaves out), downstream missing (metrics with a direct edge from the set that it leaves out) and disconnected (graph metrics with no path of any length, in either direction and through any node, to the set; the first 25 shown). Names resolve as in lookup_metric; an input that is ambiguous, outside the graph, not a metric or unknown is listed as unresolved with the reason. Use it before instrumenting a dashboard, then infer_drivers or explain_path on the candidates.", {
705
+ metrics: z.array(z.string()).min(1).describe("The metrics you track, as names, abbreviations or graph ids (e.g. ['ARR', 'NRR', 'CAC'])"),
698
706
  }, async ({ metrics }) => {
699
707
  const resolved = [];
700
708
  const unresolved = [];
@@ -705,19 +713,22 @@ server.tool("coverage_gap", "Audit a metric set against the GASP relationship gr
705
713
  }
706
714
  else {
707
715
  const r = resolveNodeResult(data, m);
708
- unresolved.push(r.kind === "ambiguous" ? `${m} (ambiguous: ${r.candidates.map((c) => `\`${metricFormulaId(data, c)}\``).join(" or ")})` : m);
716
+ unresolved.push(r.kind === "ambiguous" ? `${m} (ambiguous: ${r.candidates.map((c) => `\`${metricFormulaId(data, c)}\``).join(" or ")})`
717
+ : r.kind === "node" ? `${m} (${r.node.nodeType === "concept" ? "a concept" : "an entity"}, not a metric)`
718
+ : r.metric ? `${m} (not in the relationship graph)`
719
+ : `${m} (no match)`);
709
720
  }
710
721
  }
711
722
  if (resolved.length === 0) {
712
723
  return {
713
- content: [{ type: "text", text: `No metric nodes resolved from input: ${metrics.join(", ")}. Try ids like 'arr', 'nrr', 'cac' or labels like 'NRR'.` }],
724
+ content: [{ type: "text", text: `No input resolved to a metric in the relationship graph: ${unresolved.join(", ")}. Pass graph ids ('arr', 'nrr', 'cac') or names ('NRR', 'Net Revenue Retention').` }],
714
725
  };
715
726
  }
716
727
  const result = coverageGap(new Set(resolved), traversalCtx);
717
728
  const labelOf = (id) => traversalCtx.nodeById.get(id)?.label ?? id;
718
729
  const formatList = (ids) => ids.length === 0 ? "_(none)_" : ids.map((id) => `- ${labelOf(id)} (\`${id}\`)`).join("\n");
719
730
  const parts = [
720
- `# Coverage gap — ${resolved.length} metric${resolved.length === 1 ? "" : "s"}\n`,
731
+ `# Coverage gap: ${resolved.length} metric${resolved.length === 1 ? "" : "s"}\n`,
721
732
  `**Tracked:** ${resolved.map((id) => `${labelOf(id)} (\`${id}\`)`).join(", ")}`,
722
733
  ];
723
734
  if (unresolved.length > 0) {
@@ -725,15 +736,15 @@ server.tool("coverage_gap", "Audit a metric set against the GASP relationship gr
725
736
  }
726
737
  parts.push("");
727
738
  parts.push(`## Upstream missing (${result.upstreamMissing.length})`);
728
- parts.push(`_Direct drivers of the tracked set that aren't tracked. Add to capture root-cause signal._\n`);
739
+ parts.push(`_Metrics with a direct edge into the tracked set that you do not track. Adding them shows what moves the set._\n`);
729
740
  parts.push(formatList(result.upstreamMissing));
730
741
  parts.push("");
731
742
  parts.push(`## Downstream missing (${result.downstreamMissing.length})`);
732
- parts.push(`_Metrics directly fed by the tracked set, not tracked. Add to see what the inputs translate into._\n`);
743
+ parts.push(`_Metrics with a direct edge from the tracked set that you do not track. Adding them shows what the set moves._\n`);
733
744
  parts.push(formatList(result.downstreamMissing));
734
745
  parts.push("");
735
746
  parts.push(`## Disconnected (${result.disconnected.length})`);
736
- parts.push(`_In-graph metrics with no path (any length, either direction) to the tracked set._\n`);
747
+ parts.push(`_Graph metrics with no path of any length, in either direction, to the tracked set._\n`);
737
748
  if (result.disconnected.length > 25) {
738
749
  parts.push(formatList(result.disconnected.slice(0, 25)));
739
750
  parts.push(`\n_…and ${result.disconnected.length - 25} more._`);
@@ -745,7 +756,7 @@ server.tool("coverage_gap", "Audit a metric set against the GASP relationship gr
745
756
  });
746
757
  // Start the server
747
758
  // --- Tool 12: lookup_definition ---
748
- server.tool("lookup_definition", "Look up a GASP Standard glossary term (e.g. 'Signal Type', 'Sensitivity Tier', 'Access Scope', 'Metric Owner', 'Logo Churn Rate', 'CEL'). Returns the full definition, its section, related links and page URL. Use lookup_metric for metrics.", { term: z.string().describe("Term name, abbreviation or slug (e.g. 'Signal Type', 'CEL', 'metric-owner')") }, async ({ term }) => {
759
+ server.tool("lookup_definition", "Look up a GASP Standard glossary term (e.g. 'Signal Type', 'Sensitivity Tier', 'Access Scope', 'Metric Owner', 'CEL'): the full definition and body, its section, the canonical or reference link, where it is used and the page URL. The term must match exactly, ignoring case: the term, its slug or either part of 'Term (Abbreviation)', so 'CEL' finds 'CEL (Commercial Event Ledger)'. A metric name points you to lookup_metric and a miss returns close names. Some metrics also have a short glossary entry (Logo Churn Rate, Win Rate); lookup_metric gives their formula and benchmarks.", { term: z.string().describe("Exact term, abbreviation or slug, case-insensitive (e.g. 'Signal Type', 'CEL', 'metric-owner')") }, async ({ term }) => {
749
760
  const def = findDefinition(data, term);
750
761
  if (!def) {
751
762
  const r = resolveMetric(data, term);
package/build/loader.js CHANGED
@@ -92,7 +92,7 @@ export function loadStandard() {
92
92
  taxonomy = JSON.parse(readFileSync(taxonomyPath, "utf-8"));
93
93
  }
94
94
  catch {
95
- // taxonomy.json not yet created — default to operational
95
+ // taxonomy.json not yet created: default to operational
96
96
  }
97
97
  // Merge signal types into metrics
98
98
  for (const m of metrics) {
@@ -129,7 +129,7 @@ export function loadStandard() {
129
129
  }
130
130
  // Enrich cross-reference stubs with canonical content fields.
131
131
  //
132
- // Some metrics intentionally appear in multiple department files — e.g. NRR
132
+ // Some metrics intentionally appear in multiple department files, e.g. NRR
133
133
  // is defined canonically in core.md and listed as a "See [Core](core.md#...)"
134
134
  // stub in customer-success.md. Per-department records are needed to drive
135
135
  // department-scoped routes (e.g. /v1/metrics/customer-success/...), but
@@ -174,7 +174,7 @@ export function loadStandard() {
174
174
  m.sources = [...canon.sources];
175
175
  if (!m.aliases?.length)
176
176
  m.aliases = [...canon.aliases];
177
- // dualLens and dualLensNote are intentionally NOT enriched — they are a
177
+ // dualLens and dualLensNote are intentionally NOT enriched: they are a
178
178
  // Core-only construct (asserted by data-integrity tests). Consumers that
179
179
  // need dual-lens content should resolve via slug to the Core record.
180
180
  }
@@ -619,7 +619,9 @@ export function suggestNodes(data, name, limit = 6) {
619
619
  const byWord = data.nodes.filter((n) => n.id.includes(q) || n.label.toLowerCase().includes(q));
620
620
  const near = closest(q, data.nodes.map((n) => ({ item: n, names: [n.label, n.id] })), limit).map((c) => c.item);
621
621
  const hits = [...new Set([...byWord, ...near])].map((n) => `${n.label} (${n.id})`).slice(0, limit);
622
- return hits.length ? ` Did you mean: ${hits.join("; ")}? Pass the exact name or id.` : "";
622
+ return hits.length
623
+ ? ` Did you mean: ${hits.join("; ")}? Pass the exact name or id.`
624
+ : " No node label or id is close to it. The graph holds the Core and department metrics, entities and concepts only; use search to find the metric and lookup_metric to see whether it is in the graph.";
623
625
  }
624
626
  /**
625
627
  * Every metric that reads an entity's datasets, from ontology entityMappings