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 +13 -13
- package/build/derive.js +1 -1
- package/build/index.js +83 -72
- package/build/loader.js +6 -4
- package/build/parser.js +5 -5
- package/build/standard-data.json +1 -1
- package/build/standard-version.json +1 -1
- package/build/tools.js +6 -5
- package/build/traversal.d.ts +2 -2
- package/build/traversal.js +2 -2
- package/build/types.d.ts +1 -1
- package/build/urls.d.ts +8 -1
- package/build/urls.js +11 -0
- package/package.json +1 -1
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
|
|
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
|
|
38
|
-
| `list_metrics` |
|
|
39
|
-
| `get_formula` | Canonical formula in the GASP formula grammar, with unit, O/M forms, variants,
|
|
40
|
-
| `get_relationships` |
|
|
41
|
-
| `get_data_requirements` | Data requirements for
|
|
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
|
|
43
|
-
| `generate_query` | Compile warehouse
|
|
44
|
-
| `validate_adapter` | Validate a GASP adapter
|
|
45
|
-
| `infer_drivers` | Diagnose what could explain an observed metric movement:
|
|
46
|
-
| `explain_path` |
|
|
47
|
-
| `coverage_gap` | Audit a metric set:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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", "
|
|
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
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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", "
|
|
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:
|
|
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(`**
|
|
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", "
|
|
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}
|
|
397
|
+
`# ${node.label}: relationships\n`,
|
|
389
398
|
`**Description:** ${node.description}`,
|
|
390
399
|
`**Category:** ${node.category}`,
|
|
391
|
-
`**Level:** ${node.level}
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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", "
|
|
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("
|
|
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("
|
|
446
|
-
breakdown: z.string().optional().describe("Name of a
|
|
447
|
-
dialect: z.enum(DIALECTS).optional().describe("
|
|
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(
|
|
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", "
|
|
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", "
|
|
543
|
-
metric: z.string().describe("
|
|
544
|
-
adapter: z.string().describe("Your GASP adapter JSON string (
|
|
545
|
-
breakdown: z.string().optional().describe("Name of a
|
|
546
|
-
dialect: z.enum(DIALECTS).optional().describe("SQL dialect: postgres (also Redshift), snowflake, bigquery or databricks.
|
|
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:
|
|
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(", ")}.
|
|
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
|
|
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", "
|
|
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", "
|
|
619
|
-
metric: z.string().describe("Metric name, abbreviation
|
|
620
|
-
direction: z.enum(["up", "down"]).describe("Observed
|
|
621
|
-
maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum
|
|
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
|
|
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)}
|
|
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", "
|
|
657
|
-
from: z.string().describe("
|
|
658
|
-
to: z.string().describe("Target
|
|
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
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
|
697
|
-
metrics: z.array(z.string()).min(1).describe("
|
|
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 ")})`
|
|
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
|
|
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
|
|
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(`
|
|
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
|
|
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(`
|
|
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', '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|