gasp-standard-mcp 1.7.1 → 1.9.0

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
@@ -41,6 +41,11 @@ Restart your MCP client after adding the config.
41
41
  | `search` | Full-text search across metrics, definitions, and data requirements |
42
42
  | `generate_query` | Generate warehouse-specific SQL from a metric name + adapter mapping |
43
43
  | `validate_adapter` | Validate a GASP adapter mapping file — reports which metrics are fully/partially/not covered |
44
+ | `infer_drivers` | Diagnose what could explain an observed metric movement — ranked upstream candidates with paths and per-step edge classifications |
45
+ | `explain_path` | Enumerate edge chains between two metrics, with classification (metric_signal / structural / lifecycle / behavioral / assignment / generative) per step |
46
+ | `coverage_gap` | Audit a metric set: identify one-hop upstream/downstream metrics not in the set, plus any disconnected metrics |
47
+
48
+ The traversal tools (`infer_drivers`, `explain_path`, `coverage_gap`) return *structure* — paths, classifications, attenuation scores — not numeric forecasts. They serve diagnosis, exploration, and coverage-audit workflows respectively, and compose: use `infer_drivers` to pick candidates, `explain_path` to scrutinise a chain, then `coverage_gap` to check whether the candidate is being measured. For counterfactual "what if" simulation, use the [TWIN Modifier](https://gaspwiki.com/v1/twin-modifier).
44
49
 
45
50
  ## Data
46
51
 
@@ -56,12 +61,14 @@ Restart your MCP client after adding the config.
56
61
  - 60 temporal mappings (grain, frequency, period for every metric node)
57
62
  - Access control classifications for every graph metric (sensitivity tier + access scope)
58
63
  - CEL `change_nature` field for revenue quality analysis (Adjusted NRR/GRR)
64
+ - Per-department cross-references (e.g. NRR under Customer Success, Gross Margin under Finance) now resolve to the full canonical content via slug lookup
59
65
  - Sources: the canonical [GASP Standard](https://gaspwiki.com) files
60
66
 
61
67
  ## Examples
62
68
 
63
69
  Once installed, ask your AI assistant:
64
70
 
71
+ **Definitions and structure**
65
72
  - "What is NRR?"
66
73
  - "How do I calculate CAC Payback?"
67
74
  - "What drives ARR?"
@@ -74,6 +81,12 @@ Once installed, ask your AI assistant:
74
81
  - "What data do I need to implement ARR tracking?"
75
82
  - "Search for churn"
76
83
 
84
+ **Diagnosis, exploration and coverage** (new in 1.9.0)
85
+ - "ARR is down this month. What could explain it?" — `infer_drivers`
86
+ - "How does Win Rate eventually affect Free Cash Flow?" — `explain_path`
87
+ - "We track ARR, NRR, GRR, CAC and CAC Payback. What important metrics are we missing?" — `coverage_gap`
88
+ - "Why might NRR be slipping? Trace the path back through Health Score." — composes `infer_drivers` → `explain_path`
89
+
77
90
  ## License
78
91
 
79
- MIT
92
+ Apache-2.0
package/build/index.js CHANGED
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { loadStandard, loadBundled, loadRemote, findMetric, getRelationships, search, } from "./loader.js";
6
+ import { buildContext, inferDrivers, findPaths, coverageGap, } from "./traversal.js";
6
7
  // Load data: local repo files → bundled JSON → remote fetch from gaspwiki.com
7
8
  let data;
8
9
  try {
@@ -43,8 +44,38 @@ const VERSION_URL = "https://gaspwiki.com/api/standard-version.json";
43
44
  })();
44
45
  const server = new McpServer({
45
46
  name: "gasp-standard",
46
- version: "1.0.0",
47
+ version: "1.9.0",
47
48
  });
49
+ // Build traversal context once at startup. Used by infer_drivers, explain_path,
50
+ // and coverage_gap. Reads canonical structuredFormulas + edgeTypeClassification
51
+ // from ontology.json plus the edge `inverse` flag from relationships.json.
52
+ const traversalCtx = buildContext(data.nodes, data.edges, data.ontology);
53
+ /** Resolve a user-supplied metric name to a graph node id, mirroring getRelationships. */
54
+ function resolveNode(name) {
55
+ const q = name.toLowerCase().trim();
56
+ return (data.nodes.find((n) => n.id === q) ||
57
+ data.nodes.find((n) => n.label.toLowerCase() === q) ||
58
+ data.nodes.find((n) => n.id.includes(q) || n.label.toLowerCase().includes(q)));
59
+ }
60
+ /** Render a path as a markdown one-liner: `from → [type] to → [type] to`. */
61
+ function renderPath(path) {
62
+ if (path.length === 0)
63
+ return "(direct)";
64
+ const parts = [path[0].from];
65
+ for (const step of path) {
66
+ const flip = step.directionFlip ? " ↓" : "";
67
+ parts.push(`→ [${step.edgeType}${flip}] ${step.to}`);
68
+ }
69
+ return parts.join(" ");
70
+ }
71
+ function renderClassifications(path) {
72
+ if (path.length === 0)
73
+ return "";
74
+ const counts = new Map();
75
+ for (const s of path)
76
+ counts.set(s.classification, (counts.get(s.classification) ?? 0) + 1);
77
+ return [...counts.entries()].map(([k, v]) => `${k}×${v}`).join(", ");
78
+ }
48
79
  // --- Tool 1: lookup_metric ---
49
80
  server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbreviation. Returns definition, formula, benchmarks, time period, and department.", { name: z.string().describe("Metric name or abbreviation (e.g. 'MRR', 'Net Revenue Retention', 'CAC Payback')") }, async ({ name }) => {
50
81
  const metric = findMetric(data, name);
@@ -661,6 +692,137 @@ server.tool("validate_adapter", "Validate a GASP adapter mapping file. Takes a J
661
692
  }
662
693
  return { content: [{ type: "text", text: parts.join("\n") }] };
663
694
  });
695
+ // --- Tool 9: infer_drivers ---
696
+ 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.", {
697
+ metric: z.string().describe("Metric name, abbreviation, or graph id (e.g. 'ARR', 'NRR', 'arr')"),
698
+ direction: z.enum(["up", "down"]).describe("Observed direction of movement"),
699
+ maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum traversal depth (default 6)"),
700
+ }, async ({ metric: name, direction: dirStr, maxDepth }) => {
701
+ const node = resolveNode(name);
702
+ if (!node) {
703
+ return {
704
+ content: [{ type: "text", text: `No graph node found matching "${name}".` }],
705
+ };
706
+ }
707
+ if (!traversalCtx.metricNodeIds.has(node.id)) {
708
+ return {
709
+ content: [{ type: "text", text: `"${node.label}" is not a metric node — infer_drivers operates on metrics only. Try get_relationships for entity/concept nodes.` }],
710
+ };
711
+ }
712
+ const observedDirection = dirStr === "up" ? 1 : -1;
713
+ const candidates = inferDrivers(node.id, observedDirection, traversalCtx, { maxDepth });
714
+ if (candidates.length === 0) {
715
+ return {
716
+ content: [{ type: "text", text: `# ${node.label} ${dirStr}\n\nNo upstream candidates found within the traversal depth limit.` }],
717
+ };
718
+ }
719
+ const arrow = (d) => (d === 1 ? "↑" : "↓");
720
+ const parts = [
721
+ `# ${node.label} ${arrow(observedDirection)} — Candidate drivers (${candidates.length})\n`,
722
+ `_Structural reasoning, not numeric forecasting. Scores are attenuation products (0–1) along the chain; they rank candidates but do not estimate magnitudes._\n`,
723
+ `| # | Driver | Implied direction | Score | Path |`,
724
+ `|---|--------|------------------|-------|------|`,
725
+ ];
726
+ for (let i = 0; i < Math.min(candidates.length, 25); i++) {
727
+ const c = candidates[i];
728
+ parts.push(`| ${i + 1} | ${c.label ?? c.id} (\`${c.id}\`) | ${arrow(c.direction)} | ${c.score.toFixed(3)} | ${renderPath(c.path)} |`);
729
+ }
730
+ if (candidates.length > 25) {
731
+ parts.push(`\n_Showing top 25 of ${candidates.length}._`);
732
+ }
733
+ return { content: [{ type: "text", text: parts.join("\n") }] };
734
+ });
735
+ // --- Tool 10: explain_path ---
736
+ 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.", {
737
+ from: z.string().describe("Source metric or node (name, abbreviation, or id)"),
738
+ to: z.string().describe("Target metric or node"),
739
+ maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum chain length (default 6)"),
740
+ }, async ({ from, to, maxDepth }) => {
741
+ const fromNode = resolveNode(from);
742
+ const toNode = resolveNode(to);
743
+ if (!fromNode || !toNode) {
744
+ const missing = !fromNode ? from : to;
745
+ return {
746
+ content: [{ type: "text", text: `No graph node found matching "${missing}".` }],
747
+ };
748
+ }
749
+ const paths = findPaths(fromNode.id, toNode.id, traversalCtx, { maxDepth });
750
+ if (paths.length === 0) {
751
+ return {
752
+ content: [{
753
+ type: "text",
754
+ 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).`,
755
+ }],
756
+ };
757
+ }
758
+ const parts = [
759
+ `# ${fromNode.label} → ${toNode.label} — ${paths.length} path${paths.length === 1 ? "" : "s"}\n`,
760
+ ];
761
+ for (let i = 0; i < paths.length; i++) {
762
+ const p = paths[i];
763
+ parts.push(`## Path ${i + 1} — ${p.path.length} step${p.path.length === 1 ? "" : "s"}, score ${p.score.toFixed(3)}, end direction ${p.direction === 1 ? "↑" : "↓"}`);
764
+ parts.push(`Classifications: ${renderClassifications(p.path)}\n`);
765
+ for (let j = 0; j < p.path.length; j++) {
766
+ const s = p.path[j];
767
+ const flip = s.directionFlip ? " (↓ inverts direction)" : "";
768
+ const fromLabel = traversalCtx.nodeById.get(s.from)?.label ?? s.from;
769
+ const toLabel = traversalCtx.nodeById.get(s.to)?.label ?? s.to;
770
+ parts.push(`${j + 1}. **${fromLabel}** → **${toLabel}** — \`${s.edgeType}\` (${s.classification}, attenuation ${s.attenuation.toFixed(2)})${flip}`);
771
+ }
772
+ parts.push("");
773
+ }
774
+ return { content: [{ type: "text", text: parts.join("\n") }] };
775
+ });
776
+ // --- Tool 11: coverage_gap ---
777
+ 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.", {
778
+ metrics: z.array(z.string()).min(1).describe("Metric names, abbreviations, or graph ids (e.g. ['ARR', 'NRR', 'CAC'])"),
779
+ }, async ({ metrics }) => {
780
+ const resolved = [];
781
+ const unresolved = [];
782
+ for (const m of metrics) {
783
+ const node = resolveNode(m);
784
+ if (node && traversalCtx.metricNodeIds.has(node.id)) {
785
+ resolved.push(node.id);
786
+ }
787
+ else {
788
+ unresolved.push(m);
789
+ }
790
+ }
791
+ if (resolved.length === 0) {
792
+ return {
793
+ content: [{ type: "text", text: `No metric nodes resolved from input: ${metrics.join(", ")}. Try ids like 'arr', 'nrr', 'cac' or labels like 'NRR'.` }],
794
+ };
795
+ }
796
+ const result = coverageGap(new Set(resolved), traversalCtx);
797
+ const labelOf = (id) => traversalCtx.nodeById.get(id)?.label ?? id;
798
+ const formatList = (ids) => ids.length === 0 ? "_(none)_" : ids.map((id) => `- ${labelOf(id)} (\`${id}\`)`).join("\n");
799
+ const parts = [
800
+ `# Coverage gap — ${resolved.length} metric${resolved.length === 1 ? "" : "s"}\n`,
801
+ `**Tracked:** ${resolved.map((id) => `${labelOf(id)} (\`${id}\`)`).join(", ")}`,
802
+ ];
803
+ if (unresolved.length > 0) {
804
+ parts.push(`**Unresolved input:** ${unresolved.join(", ")}`);
805
+ }
806
+ parts.push("");
807
+ parts.push(`## Upstream missing (${result.upstreamMissing.length})`);
808
+ parts.push(`_Direct drivers of the tracked set that aren't tracked. Add to capture root-cause signal._\n`);
809
+ parts.push(formatList(result.upstreamMissing));
810
+ parts.push("");
811
+ parts.push(`## Downstream missing (${result.downstreamMissing.length})`);
812
+ parts.push(`_Metrics directly fed by the tracked set, not tracked. Add to see what the inputs translate into._\n`);
813
+ parts.push(formatList(result.downstreamMissing));
814
+ parts.push("");
815
+ parts.push(`## Disconnected (${result.disconnected.length})`);
816
+ parts.push(`_In-graph metrics with no path (any length, either direction) to the tracked set._\n`);
817
+ if (result.disconnected.length > 25) {
818
+ parts.push(formatList(result.disconnected.slice(0, 25)));
819
+ parts.push(`\n_…and ${result.disconnected.length - 25} more._`);
820
+ }
821
+ else {
822
+ parts.push(formatList(result.disconnected));
823
+ }
824
+ return { content: [{ type: "text", text: parts.join("\n") }] };
825
+ });
664
826
  // Start the server
665
827
  async function main() {
666
828
  const transport = new StdioServerTransport();
package/build/loader.js CHANGED
@@ -97,6 +97,57 @@ export function loadStandard() {
97
97
  }
98
98
  }
99
99
  }
100
+ // Enrich cross-reference stubs with canonical content fields.
101
+ //
102
+ // Some metrics intentionally appear in multiple department files — e.g. NRR
103
+ // is defined canonically in core.md and listed as a "See [Core](core.md#...)"
104
+ // stub in customer-success.md. Per-department records are needed to drive
105
+ // department-scoped routes (e.g. /v1/metrics/customer-success/...), but
106
+ // consumers of standard.json that look up by slug expect every record to
107
+ // carry full content. Without this pass, Map-based lookups silently get the
108
+ // empty stub instead of the canonical record.
109
+ //
110
+ // Strategy: keep stub identity (department, category, name, slug, rawMarkdown)
111
+ // but copy any content field that's empty on the stub from the canonical.
112
+ const canonicalBySlug = new Map();
113
+ for (const m of metrics) {
114
+ if (m.slug && m.definition && !canonicalBySlug.has(m.slug)) {
115
+ canonicalBySlug.set(m.slug, m);
116
+ }
117
+ }
118
+ for (const m of metrics) {
119
+ if (m.definition)
120
+ continue; // already canonical or non-stub
121
+ const seeMatch = m.rawMarkdown.match(/See \[([^\]]+)\]\(([^)]+)\)/);
122
+ if (!seeMatch)
123
+ continue;
124
+ const link = seeMatch[2];
125
+ const anchor = link.includes("#") ? link.split("#")[1] : null;
126
+ if (!anchor)
127
+ continue;
128
+ const canon = canonicalBySlug.get(anchor) ||
129
+ canonicalBySlug.get(m.slug) ||
130
+ [...canonicalBySlug.values()].find((c) => c.slug.includes(anchor));
131
+ if (!canon || canon === m)
132
+ continue;
133
+ if (!m.definition)
134
+ m.definition = canon.definition;
135
+ if (!m.formulas?.length)
136
+ m.formulas = [...canon.formulas];
137
+ if (!m.benchmarks)
138
+ m.benchmarks = canon.benchmarks;
139
+ if (!m.insight)
140
+ m.insight = canon.insight;
141
+ if (!m.commonMistakes?.length && canon.commonMistakes)
142
+ m.commonMistakes = [...canon.commonMistakes];
143
+ if (!m.sources?.length)
144
+ m.sources = [...canon.sources];
145
+ if (!m.aliases?.length)
146
+ m.aliases = [...canon.aliases];
147
+ // dualLens and dualLensNote are intentionally NOT enriched — they are a
148
+ // Core-only construct (asserted by data-integrity tests). Consumers that
149
+ // need dual-lens content should resolve via slug to the Core record.
150
+ }
100
151
  // Load query templates
101
152
  const queryTemplatePath = join(standardDir, "query-templates.json");
102
153
  let queryTemplates;