gasp-standard-mcp 1.8.0 → 1.10.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
@@ -33,14 +33,20 @@ Restart your MCP client after adding the config.
33
33
 
34
34
  | Tool | Description |
35
35
  |------|-------------|
36
- | `lookup_metric` | Full metric details: definition, formula, benchmarks, entity, common mistakes, sources |
36
+ | `lookup_metric` | Full metric details: definition, formula, benchmarks, entity, common mistakes, sources and page URL. 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 |
37
38
  | `list_metrics` | List all metrics, optionally filtered by department and/or signal type |
38
39
  | `get_formula` | Get formula(s) for a metric |
39
40
  | `get_relationships` | Upstream/downstream relationships for metrics, entities, or concepts — 21 relationship types across signal, lifecycle, ownership, interaction, and financial flow categories |
40
41
  | `get_data_requirements` | Data requirements for implementing a metric: fields, types, grain, source, and example SQL |
41
- | `search` | Full-text search across metrics, definitions, and data requirements |
42
+ | `search` | Full-text search across metrics, definitions, and data requirements. Metric and definition hits include their gaspwiki.com URL |
42
43
  | `generate_query` | Generate warehouse-specific SQL from a metric name + adapter mapping |
43
44
  | `validate_adapter` | Validate a GASP adapter mapping file — reports which metrics are fully/partially/not covered |
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 edge chains between two metrics, with classification (metric_signal / structural / lifecycle / behavioral / assignment / generative) per step |
47
+ | `coverage_gap` | Audit a metric set: identify one-hop upstream/downstream metrics not in the set, plus any disconnected metrics |
48
+
49
+ 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
50
 
45
51
  ## Data
46
52
 
@@ -57,12 +63,15 @@ Restart your MCP client after adding the config.
57
63
  - Access control classifications for every graph metric (sensitivity tier + access scope)
58
64
  - CEL `change_nature` field for revenue quality analysis (Adjusted NRR/GRR)
59
65
  - Per-department cross-references (e.g. NRR under Customer Success, Gross Margin under Finance) now resolve to the full canonical content via slug lookup
66
+ - Every metric and definition result carries its gaspwiki.com page URL; definitions keep their full body and reference links
67
+ - Access classifications, structured formulas and entity mappings resolve for every graph metric, including cross-reference records (e.g. NRR under Customer Success, CAC under Marketing)
60
68
  - Sources: the canonical [GASP Standard](https://gaspwiki.com) files
61
69
 
62
70
  ## Examples
63
71
 
64
72
  Once installed, ask your AI assistant:
65
73
 
74
+ **Definitions and structure**
66
75
  - "What is NRR?"
67
76
  - "How do I calculate CAC Payback?"
68
77
  - "What drives ARR?"
@@ -75,6 +84,12 @@ Once installed, ask your AI assistant:
75
84
  - "What data do I need to implement ARR tracking?"
76
85
  - "Search for churn"
77
86
 
87
+ **Diagnosis, exploration and coverage** (new in 1.9.0)
88
+ - "ARR is down this month. What could explain it?" — `infer_drivers`
89
+ - "How does Win Rate eventually affect Free Cash Flow?" — `explain_path`
90
+ - "We track ARR, NRR, GRR, CAC and CAC Payback. What important metrics are we missing?" — `coverage_gap`
91
+ - "Why might NRR be slipping? Trace the path back through Health Score." — composes `infer_drivers` → `explain_path`
92
+
78
93
  ## License
79
94
 
80
- MIT
95
+ Apache-2.0
package/build/index.js CHANGED
@@ -2,7 +2,9 @@
2
2
  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
- import { loadStandard, loadBundled, loadRemote, findMetric, getRelationships, search, } from "./loader.js";
5
+ import { loadStandard, loadBundled, loadRemote, findMetric, findDefinition, metricNodeId, getRelationships, search, } from "./loader.js";
6
+ import { buildContext, inferDrivers, findPaths, coverageGap, } from "./traversal.js";
7
+ import { SITE, metricUrl, definitionUrl, resolveDocLink } from "./urls.js";
6
8
  // Load data: local repo files → bundled JSON → remote fetch from gaspwiki.com
7
9
  let data;
8
10
  try {
@@ -43,17 +45,75 @@ const VERSION_URL = "https://gaspwiki.com/api/standard-version.json";
43
45
  })();
44
46
  const server = new McpServer({
45
47
  name: "gasp-standard",
46
- version: "1.0.0",
48
+ version: "1.9.0",
47
49
  });
50
+ // Build traversal context once at startup. Used by infer_drivers, explain_path,
51
+ // and coverage_gap. Reads canonical structuredFormulas + edgeTypeClassification
52
+ // from ontology.json plus the edge `inverse` flag from relationships.json.
53
+ const traversalCtx = buildContext(data.nodes, data.edges, data.ontology);
54
+ /** Resolve a user-supplied metric name to a graph node id, mirroring getRelationships. */
55
+ function resolveNode(name) {
56
+ const q = name.toLowerCase().trim();
57
+ return (data.nodes.find((n) => n.id === q) ||
58
+ data.nodes.find((n) => n.label.toLowerCase() === q) ||
59
+ data.nodes.find((n) => n.id.includes(q) || n.label.toLowerCase().includes(q)));
60
+ }
61
+ /** Render a path as a markdown one-liner: `from → [type] to → [type] to`. */
62
+ function renderPath(path) {
63
+ if (path.length === 0)
64
+ return "(direct)";
65
+ const parts = [path[0].from];
66
+ for (const step of path) {
67
+ const flip = step.directionFlip ? " ↓" : "";
68
+ parts.push(`→ [${step.edgeType}${flip}] ${step.to}`);
69
+ }
70
+ return parts.join(" ");
71
+ }
72
+ function renderClassifications(path) {
73
+ if (path.length === 0)
74
+ return "";
75
+ const counts = new Map();
76
+ for (const s of path)
77
+ counts.set(s.classification, (counts.get(s.classification) ?? 0) + 1);
78
+ return [...counts.entries()].map(([k, v]) => `${k}×${v}`).join(", ");
79
+ }
80
+ /** Markdown for a glossary definition, with absolute links so agents never guess URLs */
81
+ function formatDefinition(def) {
82
+ const absolutise = (md) => md.replace(/\]\((\/[^)]*)\)/g, `](${SITE}$1)`);
83
+ const parts = [`# ${def.term}`, `**Type:** Definition (${def.section})`, `**URL:** ${definitionUrl(def)}`];
84
+ parts.push(`\n**Definition:** ${absolutise(def.definition)}`);
85
+ if (def.body)
86
+ parts.push(absolutise(def.body));
87
+ const canonical = def.canonicalLink ? resolveDocLink(def.canonicalLink) : null;
88
+ if (canonical)
89
+ parts.push(`\n**Canonical definition:** ${canonical}`);
90
+ const reference = def.referenceLink ? resolveDocLink(def.referenceLink) : null;
91
+ if (reference)
92
+ parts.push(`\n**Reference:** ${reference}`);
93
+ if (def.usedIn.length > 0)
94
+ parts.push(`**Used in:** ${def.usedIn.join(", ")}`);
95
+ return parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
96
+ }
48
97
  // --- Tool 1: lookup_metric ---
49
- 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 }) => {
98
+ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbreviation. Returns definition, formula, benchmarks, time period, department and page URL. 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 or abbreviation (e.g. 'MRR', 'Net Revenue Retention', 'CAC Payback')") }, async ({ name }) => {
50
99
  const metric = findMetric(data, name);
51
100
  if (!metric) {
101
+ const def = findDefinition(data, name);
102
+ if (def) {
103
+ return {
104
+ content: [
105
+ {
106
+ type: "text",
107
+ text: `No metric named "${name}". Matching glossary term:\n\n${formatDefinition(def)}`,
108
+ },
109
+ ],
110
+ };
111
+ }
52
112
  return {
53
113
  content: [
54
114
  {
55
115
  type: "text",
56
- text: `No metric found matching "${name}". Try searching with the 'search' tool for partial matches.`,
116
+ text: `No metric or definition found matching "${name}". Try the 'search' tool for partial matches.`,
57
117
  },
58
118
  ],
59
119
  };
@@ -68,6 +128,7 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
68
128
  `**Department:** ${metric.department}`,
69
129
  `**Category:** ${metric.category}`,
70
130
  signalLine,
131
+ `**URL:** ${metricUrl(metric)}`,
71
132
  ];
72
133
  if (metric.definition) {
73
134
  parts.push(`\n**Definition:** ${metric.definition}`);
@@ -94,23 +155,15 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
94
155
  if (metric.aliases.length > 0) {
95
156
  parts.push(`\n**Aliases:** ${metric.aliases.join(", ")}`);
96
157
  }
158
+ // Graph node id keys every ontology lookup below
159
+ const nodeId = metricNodeId(data, metric);
97
160
  // Entity mapping from ontology
98
- if (data.ontology?.entityMappings) {
99
- // Build reverse map: graph node ID → entity
100
- for (const [entityId, metricIds] of Object.entries(data.ontology.entityMappings)) {
101
- // Check if any alias/abbreviation of this metric matches a graph node ID
102
- const lowerAliases = metric.aliases.map(a => a.toLowerCase().replace(/\s+/g, '_'));
103
- const lowerName = metric.name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
104
- const matched = metricIds.some(id => lowerAliases.includes(id) ||
105
- lowerName.startsWith(id.replace(/_/g, '_')) ||
106
- metric.slug.startsWith(id.replace(/_/g, '-')));
107
- if (matched) {
108
- const entity = data.ontology.entities.find(e => e.id === entityId);
109
- if (entity) {
110
- parts.push(`\n**Measures:** ${entity.label} (${entity.aliases.join(", ")})`);
111
- }
112
- break;
113
- }
161
+ if (nodeId && data.ontology?.entityMappings) {
162
+ const entityId = Object.entries(data.ontology.entityMappings)
163
+ .find(([, metricIds]) => metricIds.includes(nodeId))?.[0];
164
+ const entity = entityId && data.ontology.entities.find(e => e.id === entityId);
165
+ if (entity) {
166
+ parts.push(`\n**Measures:** ${entity.label} (${entity.aliases.join(", ")})`);
114
167
  }
115
168
  }
116
169
  // Dual-lens content
@@ -150,27 +203,28 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
150
203
  }
151
204
  // Adjusted variant from structured formulas (e.g. NRR, GRR)
152
205
  const sfMap = data.ontology?.structuredFormulas;
153
- if (sfMap) {
154
- const nodeId = metric.slug.replace(/-/g, '_');
155
- const sf = sfMap[nodeId]
156
- || Object.entries(sfMap).find(([k]) => k !== 'description' && nodeId.startsWith(k + '_'))?.[1];
206
+ if (sfMap && nodeId) {
207
+ const sf = sfMap[nodeId];
157
208
  if (sf && typeof sf === 'object' && 'adjustedVariant' in sf) {
158
209
  parts.push(`\n## Adjusted Variant\n\n${sf.adjustedVariant}`);
159
210
  }
160
211
  }
161
212
  // Access control classification (if available)
162
213
  const acMap = data.ontology?.accessControl?.classifications;
163
- if (acMap) {
164
- const nodeId = metric.slug.replace(/-/g, '_');
165
- const ac = acMap[nodeId]
166
- || Object.entries(acMap).find(([k]) => nodeId.startsWith(k + '_'))?.[1];
214
+ if (acMap && nodeId) {
215
+ const ac = acMap[nodeId];
167
216
  if (ac) {
168
217
  parts.push(`\n## Recommended Access Control\n` +
169
- `- **Sensitivity:** ${ac.sensitivityTier} (https://gaspwiki.com/v1/definitions/sensitivity-tier)\n` +
170
- `- **Scope:** ${ac.accessScope} (https://gaspwiki.com/v1/definitions/access-scope)\n` +
171
- `See https://gaspwiki.com/v1/governance for policy patterns (Cedar, OPA, warehouse RLS).`);
218
+ `- **Sensitivity:** ${ac.sensitivityTier} (${SITE}/v1/definitions/sensitivity-tier)\n` +
219
+ `- **Scope:** ${ac.accessScope} (${SITE}/v1/definitions/access-scope)\n` +
220
+ `See ${SITE}/v1/governance for policy patterns (Cedar, OPA, warehouse RLS).`);
172
221
  }
173
222
  }
223
+ // A glossary term with the exact name the caller asked for
224
+ const shadowed = data.definitions.find((d) => d.term.toLowerCase() === name.toLowerCase().trim());
225
+ if (shadowed) {
226
+ parts.push(`\n**See also:** glossary term "${shadowed.term}" (${definitionUrl(shadowed)}), via lookup_definition`);
227
+ }
174
228
  // Swap EOM → GASP branding
175
229
  const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
176
230
  return { content: [{ type: "text", text }] };
@@ -265,17 +319,8 @@ server.tool("get_formula", "Get the formula(s) for a GASP Standard metric. Retur
265
319
  }
266
320
  // Append structured formula references if available
267
321
  const formulas = data.ontology?.structuredFormulas;
268
- const nodeId = metric.slug.replace(/-/g, '_');
269
- // Match: exact node ID, or slug starts with a formula key (e.g. "arr_annual_recurring_revenue" starts with "arr")
270
- let structured = formulas?.[nodeId];
271
- if (!structured && formulas) {
272
- for (const [key, val] of Object.entries(formulas)) {
273
- if (key !== 'description' && nodeId.startsWith(key + '_')) {
274
- structured = val;
275
- break;
276
- }
277
- }
278
- }
322
+ const nodeId = metricNodeId(data, metric);
323
+ const structured = nodeId ? formulas?.[nodeId] : undefined;
279
324
  if (structured) {
280
325
  if (structured.inputs && structured.inputs.length > 0) {
281
326
  parts.push("## Inputs (metric dependencies)");
@@ -422,7 +467,8 @@ server.tool("search", "Search across all GASP Standard metrics and definitions.
422
467
  for (const r of results) {
423
468
  const dept = r.department ? ` [${r.department}]` : "";
424
469
  parts.push(`### ${r.name}${dept}`);
425
- parts.push(`*${r.type}*: ${r.snippet}\n`);
470
+ parts.push(`*${r.type}*: ${r.snippet}`);
471
+ parts.push(r.url ? `URL: ${r.url}\n` : "");
426
472
  }
427
473
  return { content: [{ type: "text", text: parts.join("\n") }] };
428
474
  });
@@ -661,7 +707,150 @@ server.tool("validate_adapter", "Validate a GASP adapter mapping file. Takes a J
661
707
  }
662
708
  return { content: [{ type: "text", text: parts.join("\n") }] };
663
709
  });
710
+ // --- Tool 9: infer_drivers ---
711
+ 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.", {
712
+ metric: z.string().describe("Metric name, abbreviation, or graph id (e.g. 'ARR', 'NRR', 'arr')"),
713
+ direction: z.enum(["up", "down"]).describe("Observed direction of movement"),
714
+ maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum traversal depth (default 6)"),
715
+ }, async ({ metric: name, direction: dirStr, maxDepth }) => {
716
+ const node = resolveNode(name);
717
+ if (!node) {
718
+ return {
719
+ content: [{ type: "text", text: `No graph node found matching "${name}".` }],
720
+ };
721
+ }
722
+ if (!traversalCtx.metricNodeIds.has(node.id)) {
723
+ return {
724
+ content: [{ type: "text", text: `"${node.label}" is not a metric node — infer_drivers operates on metrics only. Try get_relationships for entity/concept nodes.` }],
725
+ };
726
+ }
727
+ const observedDirection = dirStr === "up" ? 1 : -1;
728
+ const candidates = inferDrivers(node.id, observedDirection, traversalCtx, { maxDepth });
729
+ if (candidates.length === 0) {
730
+ return {
731
+ content: [{ type: "text", text: `# ${node.label} ${dirStr}\n\nNo upstream candidates found within the traversal depth limit.` }],
732
+ };
733
+ }
734
+ const arrow = (d) => (d === 1 ? "↑" : "↓");
735
+ const parts = [
736
+ `# ${node.label} ${arrow(observedDirection)} — Candidate drivers (${candidates.length})\n`,
737
+ `_Structural reasoning, not numeric forecasting. Scores are attenuation products (0–1) along the chain; they rank candidates but do not estimate magnitudes._\n`,
738
+ `| # | Driver | Implied direction | Score | Path |`,
739
+ `|---|--------|------------------|-------|------|`,
740
+ ];
741
+ for (let i = 0; i < Math.min(candidates.length, 25); i++) {
742
+ const c = candidates[i];
743
+ parts.push(`| ${i + 1} | ${c.label ?? c.id} (\`${c.id}\`) | ${arrow(c.direction)} | ${c.score.toFixed(3)} | ${renderPath(c.path)} |`);
744
+ }
745
+ if (candidates.length > 25) {
746
+ parts.push(`\n_Showing top 25 of ${candidates.length}._`);
747
+ }
748
+ return { content: [{ type: "text", text: parts.join("\n") }] };
749
+ });
750
+ // --- Tool 10: explain_path ---
751
+ 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.", {
752
+ from: z.string().describe("Source metric or node (name, abbreviation, or id)"),
753
+ to: z.string().describe("Target metric or node"),
754
+ maxDepth: z.number().int().min(1).max(8).optional().describe("Maximum chain length (default 6)"),
755
+ }, async ({ from, to, maxDepth }) => {
756
+ const fromNode = resolveNode(from);
757
+ const toNode = resolveNode(to);
758
+ if (!fromNode || !toNode) {
759
+ const missing = !fromNode ? from : to;
760
+ return {
761
+ content: [{ type: "text", text: `No graph node found matching "${missing}".` }],
762
+ };
763
+ }
764
+ const paths = findPaths(fromNode.id, toNode.id, traversalCtx, { maxDepth });
765
+ if (paths.length === 0) {
766
+ return {
767
+ content: [{
768
+ type: "text",
769
+ 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).`,
770
+ }],
771
+ };
772
+ }
773
+ const parts = [
774
+ `# ${fromNode.label} → ${toNode.label} — ${paths.length} path${paths.length === 1 ? "" : "s"}\n`,
775
+ ];
776
+ for (let i = 0; i < paths.length; i++) {
777
+ const p = paths[i];
778
+ parts.push(`## Path ${i + 1} — ${p.path.length} step${p.path.length === 1 ? "" : "s"}, score ${p.score.toFixed(3)}, end direction ${p.direction === 1 ? "↑" : "↓"}`);
779
+ parts.push(`Classifications: ${renderClassifications(p.path)}\n`);
780
+ for (let j = 0; j < p.path.length; j++) {
781
+ const s = p.path[j];
782
+ const flip = s.directionFlip ? " (↓ inverts direction)" : "";
783
+ const fromLabel = traversalCtx.nodeById.get(s.from)?.label ?? s.from;
784
+ const toLabel = traversalCtx.nodeById.get(s.to)?.label ?? s.to;
785
+ parts.push(`${j + 1}. **${fromLabel}** → **${toLabel}** — \`${s.edgeType}\` (${s.classification}, attenuation ${s.attenuation.toFixed(2)})${flip}`);
786
+ }
787
+ parts.push("");
788
+ }
789
+ return { content: [{ type: "text", text: parts.join("\n") }] };
790
+ });
791
+ // --- Tool 11: coverage_gap ---
792
+ 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.", {
793
+ metrics: z.array(z.string()).min(1).describe("Metric names, abbreviations, or graph ids (e.g. ['ARR', 'NRR', 'CAC'])"),
794
+ }, async ({ metrics }) => {
795
+ const resolved = [];
796
+ const unresolved = [];
797
+ for (const m of metrics) {
798
+ const node = resolveNode(m);
799
+ if (node && traversalCtx.metricNodeIds.has(node.id)) {
800
+ resolved.push(node.id);
801
+ }
802
+ else {
803
+ unresolved.push(m);
804
+ }
805
+ }
806
+ if (resolved.length === 0) {
807
+ return {
808
+ content: [{ type: "text", text: `No metric nodes resolved from input: ${metrics.join(", ")}. Try ids like 'arr', 'nrr', 'cac' or labels like 'NRR'.` }],
809
+ };
810
+ }
811
+ const result = coverageGap(new Set(resolved), traversalCtx);
812
+ const labelOf = (id) => traversalCtx.nodeById.get(id)?.label ?? id;
813
+ const formatList = (ids) => ids.length === 0 ? "_(none)_" : ids.map((id) => `- ${labelOf(id)} (\`${id}\`)`).join("\n");
814
+ const parts = [
815
+ `# Coverage gap — ${resolved.length} metric${resolved.length === 1 ? "" : "s"}\n`,
816
+ `**Tracked:** ${resolved.map((id) => `${labelOf(id)} (\`${id}\`)`).join(", ")}`,
817
+ ];
818
+ if (unresolved.length > 0) {
819
+ parts.push(`**Unresolved input:** ${unresolved.join(", ")}`);
820
+ }
821
+ parts.push("");
822
+ parts.push(`## Upstream missing (${result.upstreamMissing.length})`);
823
+ parts.push(`_Direct drivers of the tracked set that aren't tracked. Add to capture root-cause signal._\n`);
824
+ parts.push(formatList(result.upstreamMissing));
825
+ parts.push("");
826
+ parts.push(`## Downstream missing (${result.downstreamMissing.length})`);
827
+ parts.push(`_Metrics directly fed by the tracked set, not tracked. Add to see what the inputs translate into._\n`);
828
+ parts.push(formatList(result.downstreamMissing));
829
+ parts.push("");
830
+ parts.push(`## Disconnected (${result.disconnected.length})`);
831
+ parts.push(`_In-graph metrics with no path (any length, either direction) to the tracked set._\n`);
832
+ if (result.disconnected.length > 25) {
833
+ parts.push(formatList(result.disconnected.slice(0, 25)));
834
+ parts.push(`\n_…and ${result.disconnected.length - 25} more._`);
835
+ }
836
+ else {
837
+ parts.push(formatList(result.disconnected));
838
+ }
839
+ return { content: [{ type: "text", text: parts.join("\n") }] };
840
+ });
664
841
  // Start the server
842
+ // --- Tool 12: lookup_definition ---
843
+ server.tool("lookup_definition", "Look up a GASP Standard glossary term (e.g. 'Signal Type', 'Sensitivity Tier', 'Access Scope', 'Metric Owner', 'Churn', '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 }) => {
844
+ const def = findDefinition(data, term);
845
+ if (!def) {
846
+ const metric = findMetric(data, term);
847
+ const hint = metric
848
+ ? ` "${metric.name}" is a metric: use lookup_metric (${metricUrl(metric)}).`
849
+ : " Try the 'search' tool for partial matches.";
850
+ return { content: [{ type: "text", text: `No definition found matching "${term}".${hint}` }] };
851
+ }
852
+ return { content: [{ type: "text", text: formatDefinition(def) }] };
853
+ });
665
854
  async function main() {
666
855
  const transport = new StdioServerTransport();
667
856
  await server.connect(transport);
package/build/loader.d.ts CHANGED
@@ -1,22 +1,35 @@
1
- import { Metric, RelationshipNode, StandardData } from "./types.js";
1
+ import { Metric, Definition, RelationshipNode, StandardData } from "./types.js";
2
2
  /** Load pre-parsed data bundled with the npm package (offline fallback) */
3
3
  export declare function loadBundled(): StandardData;
4
4
  /** Fetch pre-parsed standard data from gaspwiki.com (for global/npx installs) */
5
5
  export declare function loadRemote(): Promise<StandardData>;
6
6
  /** Load all standard data from local files at startup */
7
7
  export declare function loadStandard(): StandardData;
8
+ /**
9
+ * Graph node id for a metric record, used to key ontology lookups.
10
+ * Data bundled before metricNodeIds existed falls back to an exact slug match.
11
+ */
12
+ export declare function metricNodeId(data: StandardData, metric: Pick<Metric, "slug">): string | undefined;
8
13
  /**
9
14
  * Search metrics + definitions for a query string.
10
15
  * Returns top N matches ranked by relevance.
11
16
  */
12
- export declare function search(data: StandardData, query: string, limit?: number): Array<{
17
+ export interface SearchResult {
13
18
  type: "metric" | "definition" | "data_requirement";
14
19
  name: string;
15
20
  snippet: string;
16
21
  department?: string;
17
- }>;
22
+ /** gaspwiki.com page for this result (absent for data requirements) */
23
+ url?: string;
24
+ }
25
+ export declare function search(data: StandardData, query: string, limit?: number): SearchResult[];
18
26
  /** Find a metric by name, abbreviation, or slug */
19
27
  export declare function findMetric(data: StandardData, name: string): Metric | undefined;
28
+ /**
29
+ * Find a glossary definition by term, slug or abbreviation.
30
+ * "CEL" matches "CEL (Commercial Event Ledger)" via the parenthetical.
31
+ */
32
+ export declare function findDefinition(data: StandardData, name: string): Definition | undefined;
20
33
  /** Get relationships for a metric: upstream (what drives it) and downstream (what it drives) */
21
34
  export declare function getRelationships(data: StandardData, metricId: string): {
22
35
  node: RelationshipNode | undefined;
package/build/loader.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseMetricsFile, parseDefinitions } from "./parser.js";
4
+ import { metricUrl, definitionUrl } from "./urls.js";
4
5
  /** Resolve the repo root (two levels up from build/index.js) */
5
6
  function repoRoot() {
6
7
  // gasp-wiki-mcp/build/index.js -> gasp-wiki-mcp -> repo root
@@ -42,6 +43,24 @@ export function loadStandard() {
42
43
  const relData = JSON.parse(readFileSync(relPath, "utf-8"));
43
44
  const nodes = relData.nodes;
44
45
  const edges = relData.edges;
46
+ // Metric slug → graph node id, from the website's node → page map.
47
+ // Stubs that share the canonical slug resolve directly.
48
+ const linksPath = join(root, "gasp-wiki-frontend", "src", "data", "metric-links.ts");
49
+ const metricNodeIds = {};
50
+ const linkRe = /^\s*(\w+):\s*\{\s*href:\s*'\/v1\/metrics\/[a-z0-9-]+\/([a-z0-9-]+)'/gm;
51
+ for (const [, nodeId, slug] of readFileSync(linksPath, "utf-8").matchAll(linkRe)) {
52
+ metricNodeIds[slug] = nodeId;
53
+ }
54
+ // Node id → canonical slug, taken before stubs are added
55
+ const slugByNodeId = new Map(Object.entries(metricNodeIds).map(([slug, id]) => [id, slug]));
56
+ // Stubs with their own slug ("See [Core](core.md#cac-customer-acquisition-cost)")
57
+ // inherit the node of the metric they point to.
58
+ for (const m of metrics) {
59
+ const anchor = m.rawMarkdown.match(/See \[[^\]]+\]\([^)#]*#([^)]+)\)/)?.[1];
60
+ if (anchor && metricNodeIds[anchor] && !metricNodeIds[m.slug]) {
61
+ metricNodeIds[m.slug] = metricNodeIds[anchor];
62
+ }
63
+ }
45
64
  // Load data requirements from dashboard
46
65
  const dashPath = join(root, "gasp-wiki-frontend", "src", "data", "dashboard-mock.json");
47
66
  const dashData = JSON.parse(readFileSync(dashPath, "utf-8"));
@@ -206,24 +225,24 @@ export function loadStandard() {
206
225
  node.signalType = node.signalType || '';
207
226
  node.category = node.category || node.department || '';
208
227
  if (!node.description) {
209
- // Build slug from the metric-links mapping pattern
210
- const slug = node.id.replace(/_/g, '-');
211
- // Search for a matching definition
212
- for (const [key, def] of defBySlug) {
213
- if (key.includes(slug) || slug.includes(key.split('-')[0])) {
214
- node.description = def;
215
- break;
216
- }
217
- }
228
+ const slug = slugByNodeId.get(node.id);
229
+ node.description = (slug && defBySlug.get(slug)) || '';
218
230
  }
219
231
  }
220
232
  }
221
- return { metrics, definitions, nodes, edges, dataRequirements, ontology, queryTemplates };
233
+ return { metrics, definitions, nodes, edges, dataRequirements, ontology, queryTemplates, metricNodeIds };
222
234
  }
223
235
  /**
224
- * Search metrics + definitions for a query string.
225
- * Returns top N matches ranked by relevance.
236
+ * Graph node id for a metric record, used to key ontology lookups.
237
+ * Data bundled before metricNodeIds existed falls back to an exact slug match.
226
238
  */
239
+ export function metricNodeId(data, metric) {
240
+ const mapped = data.metricNodeIds?.[metric.slug];
241
+ if (mapped)
242
+ return mapped;
243
+ const id = metric.slug.replace(/-/g, "_");
244
+ return data.nodes.some((n) => n.id === id) ? id : undefined;
245
+ }
227
246
  export function search(data, query, limit = 10) {
228
247
  const q = query.toLowerCase();
229
248
  const results = [];
@@ -236,6 +255,7 @@ export function search(data, query, limit = 10) {
236
255
  name: m.name,
237
256
  snippet: `[${signalLabel}] ${m.definition || m.insight || ""}`,
238
257
  department: m.department,
258
+ url: metricUrl(m),
239
259
  score,
240
260
  });
241
261
  }
@@ -247,6 +267,7 @@ export function search(data, query, limit = 10) {
247
267
  type: "definition",
248
268
  name: d.term,
249
269
  snippet: d.definition,
270
+ url: definitionUrl(d),
250
271
  score,
251
272
  });
252
273
  }
@@ -332,6 +353,23 @@ export function findMetric(data, name) {
332
353
  data.metrics.find((m) => m.name.toLowerCase().includes(q)) ||
333
354
  data.metrics.find((m) => m.aliases.some((a) => a.toLowerCase().includes(q))));
334
355
  }
356
+ /**
357
+ * Find a glossary definition by term, slug or abbreviation.
358
+ * "CEL" matches "CEL (Commercial Event Ledger)" via the parenthetical.
359
+ */
360
+ export function findDefinition(data, name) {
361
+ const q = name.toLowerCase().trim();
362
+ if (!q)
363
+ return undefined;
364
+ const parts = (term) => {
365
+ const m = term.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
366
+ return m ? [m[1].toLowerCase().trim(), m[2].toLowerCase().trim()] : [];
367
+ };
368
+ return (data.definitions.find((d) => d.term.toLowerCase() === q) ||
369
+ data.definitions.find((d) => d.slug === q) ||
370
+ data.definitions.find((d) => parts(d.term).includes(q)) ||
371
+ data.definitions.find((d) => d.term.toLowerCase().includes(q)));
372
+ }
335
373
  /** Get relationships for a metric: upstream (what drives it) and downstream (what it drives) */
336
374
  export function getRelationships(data, metricId) {
337
375
  const q = metricId.toLowerCase().trim();
package/build/parser.js CHANGED
@@ -346,6 +346,15 @@ export function parseDefinitions(content) {
346
346
  // Extract canonical link
347
347
  const linkMatch = raw.match(/\*\*Canonical definition:\*\*\s*\[([^\]]+)\]\(([^)]+)\)/);
348
348
  const canonicalLink = linkMatch ? linkMatch[2] : null;
349
+ // Extract reference link (framework terms point at a site page instead)
350
+ const refMatch = raw.match(/\*\*Reference:\*\*\s*\[([^\]]+)\]\(([^)]+)\)/);
351
+ const referenceLink = refMatch ? refMatch[2] : null;
352
+ // Remaining body (bullets, examples) minus the link/metadata lines
353
+ const bodyLines = raw.split("\n").filter((l) => l !== defLine && !l.startsWith("---"));
354
+ const body = bodyLines
355
+ .filter((l) => !/\*\*(Canonical definition|Reference|Used in):\*\*/.test(l))
356
+ .join("\n")
357
+ .trim();
349
358
  // Extract "Used in" departments
350
359
  const usedInMatch = raw.match(/\*\*Used in:\*\*\s*(.+)/);
351
360
  const usedIn = usedInMatch
@@ -357,6 +366,8 @@ export function parseDefinitions(content) {
357
366
  definition,
358
367
  section: currentSection,
359
368
  canonicalLink,
369
+ referenceLink,
370
+ body: body || null,
360
371
  usedIn,
361
372
  });
362
373
  continue;