gasp-standard-mcp 1.9.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,12 +33,13 @@ 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 |
44
45
  | `infer_drivers` | Diagnose what could explain an observed metric movement — ranked upstream candidates with paths and per-step edge classifications |
@@ -62,6 +63,8 @@ The traversal tools (`infer_drivers`, `explain_path`, `coverage_gap`) return *st
62
63
  - Access control classifications for every graph metric (sensitivity tier + access scope)
63
64
  - CEL `change_nature` field for revenue quality analysis (Adjusted NRR/GRR)
64
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)
65
68
  - Sources: the canonical [GASP Standard](https://gaspwiki.com) files
66
69
 
67
70
  ## Examples
package/build/index.js CHANGED
@@ -2,8 +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
6
  import { buildContext, inferDrivers, findPaths, coverageGap, } from "./traversal.js";
7
+ import { SITE, metricUrl, definitionUrl, resolveDocLink } from "./urls.js";
7
8
  // Load data: local repo files → bundled JSON → remote fetch from gaspwiki.com
8
9
  let data;
9
10
  try {
@@ -76,15 +77,43 @@ function renderClassifications(path) {
76
77
  counts.set(s.classification, (counts.get(s.classification) ?? 0) + 1);
77
78
  return [...counts.entries()].map(([k, v]) => `${k}×${v}`).join(", ");
78
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
+ }
79
97
  // --- Tool 1: lookup_metric ---
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 }) => {
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 }) => {
81
99
  const metric = findMetric(data, name);
82
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
+ }
83
112
  return {
84
113
  content: [
85
114
  {
86
115
  type: "text",
87
- 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.`,
88
117
  },
89
118
  ],
90
119
  };
@@ -99,6 +128,7 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
99
128
  `**Department:** ${metric.department}`,
100
129
  `**Category:** ${metric.category}`,
101
130
  signalLine,
131
+ `**URL:** ${metricUrl(metric)}`,
102
132
  ];
103
133
  if (metric.definition) {
104
134
  parts.push(`\n**Definition:** ${metric.definition}`);
@@ -125,23 +155,15 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
125
155
  if (metric.aliases.length > 0) {
126
156
  parts.push(`\n**Aliases:** ${metric.aliases.join(", ")}`);
127
157
  }
158
+ // Graph node id keys every ontology lookup below
159
+ const nodeId = metricNodeId(data, metric);
128
160
  // Entity mapping from ontology
129
- if (data.ontology?.entityMappings) {
130
- // Build reverse map: graph node ID → entity
131
- for (const [entityId, metricIds] of Object.entries(data.ontology.entityMappings)) {
132
- // Check if any alias/abbreviation of this metric matches a graph node ID
133
- const lowerAliases = metric.aliases.map(a => a.toLowerCase().replace(/\s+/g, '_'));
134
- const lowerName = metric.name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
135
- const matched = metricIds.some(id => lowerAliases.includes(id) ||
136
- lowerName.startsWith(id.replace(/_/g, '_')) ||
137
- metric.slug.startsWith(id.replace(/_/g, '-')));
138
- if (matched) {
139
- const entity = data.ontology.entities.find(e => e.id === entityId);
140
- if (entity) {
141
- parts.push(`\n**Measures:** ${entity.label} (${entity.aliases.join(", ")})`);
142
- }
143
- break;
144
- }
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(", ")})`);
145
167
  }
146
168
  }
147
169
  // Dual-lens content
@@ -181,27 +203,28 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
181
203
  }
182
204
  // Adjusted variant from structured formulas (e.g. NRR, GRR)
183
205
  const sfMap = data.ontology?.structuredFormulas;
184
- if (sfMap) {
185
- const nodeId = metric.slug.replace(/-/g, '_');
186
- const sf = sfMap[nodeId]
187
- || Object.entries(sfMap).find(([k]) => k !== 'description' && nodeId.startsWith(k + '_'))?.[1];
206
+ if (sfMap && nodeId) {
207
+ const sf = sfMap[nodeId];
188
208
  if (sf && typeof sf === 'object' && 'adjustedVariant' in sf) {
189
209
  parts.push(`\n## Adjusted Variant\n\n${sf.adjustedVariant}`);
190
210
  }
191
211
  }
192
212
  // Access control classification (if available)
193
213
  const acMap = data.ontology?.accessControl?.classifications;
194
- if (acMap) {
195
- const nodeId = metric.slug.replace(/-/g, '_');
196
- const ac = acMap[nodeId]
197
- || Object.entries(acMap).find(([k]) => nodeId.startsWith(k + '_'))?.[1];
214
+ if (acMap && nodeId) {
215
+ const ac = acMap[nodeId];
198
216
  if (ac) {
199
217
  parts.push(`\n## Recommended Access Control\n` +
200
- `- **Sensitivity:** ${ac.sensitivityTier} (https://gaspwiki.com/v1/definitions/sensitivity-tier)\n` +
201
- `- **Scope:** ${ac.accessScope} (https://gaspwiki.com/v1/definitions/access-scope)\n` +
202
- `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).`);
203
221
  }
204
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
+ }
205
228
  // Swap EOM → GASP branding
206
229
  const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
207
230
  return { content: [{ type: "text", text }] };
@@ -296,17 +319,8 @@ server.tool("get_formula", "Get the formula(s) for a GASP Standard metric. Retur
296
319
  }
297
320
  // Append structured formula references if available
298
321
  const formulas = data.ontology?.structuredFormulas;
299
- const nodeId = metric.slug.replace(/-/g, '_');
300
- // Match: exact node ID, or slug starts with a formula key (e.g. "arr_annual_recurring_revenue" starts with "arr")
301
- let structured = formulas?.[nodeId];
302
- if (!structured && formulas) {
303
- for (const [key, val] of Object.entries(formulas)) {
304
- if (key !== 'description' && nodeId.startsWith(key + '_')) {
305
- structured = val;
306
- break;
307
- }
308
- }
309
- }
322
+ const nodeId = metricNodeId(data, metric);
323
+ const structured = nodeId ? formulas?.[nodeId] : undefined;
310
324
  if (structured) {
311
325
  if (structured.inputs && structured.inputs.length > 0) {
312
326
  parts.push("## Inputs (metric dependencies)");
@@ -453,7 +467,8 @@ server.tool("search", "Search across all GASP Standard metrics and definitions.
453
467
  for (const r of results) {
454
468
  const dept = r.department ? ` [${r.department}]` : "";
455
469
  parts.push(`### ${r.name}${dept}`);
456
- parts.push(`*${r.type}*: ${r.snippet}\n`);
470
+ parts.push(`*${r.type}*: ${r.snippet}`);
471
+ parts.push(r.url ? `URL: ${r.url}\n` : "");
457
472
  }
458
473
  return { content: [{ type: "text", text: parts.join("\n") }] };
459
474
  });
@@ -824,6 +839,18 @@ server.tool("coverage_gap", "Audit a metric set against the GASP relationship gr
824
839
  return { content: [{ type: "text", text: parts.join("\n") }] };
825
840
  });
826
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
+ });
827
854
  async function main() {
828
855
  const transport = new StdioServerTransport();
829
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;