gasp-standard-mcp 1.3.1 → 1.5.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,19 +33,26 @@ 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, common mistakes, sources |
37
- | `list_metrics` | List all metrics, optionally filtered by department |
36
+ | `lookup_metric` | Full metric details: definition, formula, benchmarks, entity, common mistakes, sources |
37
+ | `list_metrics` | List all metrics, optionally filtered by department and/or signal type |
38
38
  | `get_formula` | Get formula(s) for a metric |
39
- | `get_relationships` | Upstream and downstream metric relationships |
39
+ | `get_relationships` | Upstream/downstream relationships for metrics, entities, or concepts — includes business model edges (OWNS, GRANTS, CONVERTS_TO, etc.) |
40
40
  | `get_data_requirements` | Data requirements for implementing a metric: fields, types, grain, source, and example SQL |
41
41
  | `search` | Full-text search across metrics, definitions, and data requirements |
42
+ | `generate_query` | Generate warehouse-specific SQL from a metric name + adapter mapping |
43
+ | `validate_adapter` | Validate a GASP adapter mapping file — reports which metrics are fully/partially/not covered |
42
44
 
43
45
  ## Data
44
46
 
45
47
  - 300 metrics across 13 departments
46
48
  - 80 glossary definitions
47
- - 22 relationship nodes, 34 edges
49
+ - 76 relationship nodes (60 metrics + 11 entities + 5 concepts), 157 edges
50
+ - 15 entity-to-entity business model edges (OWNS, GRANTS, CONVERTS_TO, GENERATES, etc.)
48
51
  - 78 data requirements with field types, grain, and example SQL
52
+ - 11 business entities, 5 business concepts, 22 source categories, 198 canonical fields
53
+ - 60 structured formulas with decomposed inputs (metric refs + field refs)
54
+ - 60 temporal mappings (grain, frequency, period for every metric node)
55
+ - CEL `change_nature` field for revenue quality analysis (Adjusted NRR/GRR)
49
56
  - Sources: the canonical [GASP Standard](https://gaspwiki.com) files
50
57
 
51
58
  ## Examples
@@ -55,6 +62,7 @@ Once installed, ask your AI assistant:
55
62
  - "What is NRR?"
56
63
  - "How do I calculate CAC Payback?"
57
64
  - "What drives ARR?"
65
+ - "How does Customer relate to Subscription?"
58
66
  - "List all marketing metrics"
59
67
  - "What data do I need to implement ARR tracking?"
60
68
  - "Search for churn"
package/build/index.js CHANGED
@@ -94,6 +94,25 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
94
94
  if (metric.aliases.length > 0) {
95
95
  parts.push(`\n**Aliases:** ${metric.aliases.join(", ")}`);
96
96
  }
97
+ // 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
+ }
114
+ }
115
+ }
97
116
  // Dual-lens content
98
117
  if (metric.dualLens) {
99
118
  const dl = metric.dualLens;
@@ -129,6 +148,16 @@ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbrevia
129
148
  if (metric.dualLensNote) {
130
149
  parts.push(`\n## Dual-Lens Note\n\n${metric.dualLensNote}`);
131
150
  }
151
+ // Adjusted variant from structured formulas (e.g. NRR, GRR)
152
+ 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];
157
+ if (sf && typeof sf === 'object' && 'adjustedVariant' in sf) {
158
+ parts.push(`\n## Adjusted Variant\n\n${sf.adjustedVariant}`);
159
+ }
160
+ }
132
161
  // Swap EOM → GASP branding
133
162
  const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
134
163
  return { content: [{ type: "text", text }] };
@@ -221,11 +250,40 @@ server.tool("get_formula", "Get the formula(s) for a GASP Standard metric. Retur
221
250
  }
222
251
  parts.push("```\n" + metric.formulas[i] + "\n```\n");
223
252
  }
253
+ // Append structured formula references if available
254
+ const formulas = data.ontology?.structuredFormulas;
255
+ const nodeId = metric.slug.replace(/-/g, '_');
256
+ // Match: exact node ID, or slug starts with a formula key (e.g. "arr_annual_recurring_revenue" starts with "arr")
257
+ let structured = formulas?.[nodeId];
258
+ if (!structured && formulas) {
259
+ for (const [key, val] of Object.entries(formulas)) {
260
+ if (key !== 'description' && nodeId.startsWith(key + '_')) {
261
+ structured = val;
262
+ break;
263
+ }
264
+ }
265
+ }
266
+ if (structured) {
267
+ if (structured.inputs && structured.inputs.length > 0) {
268
+ parts.push("## Inputs (metric dependencies)");
269
+ for (const input of structured.inputs) {
270
+ const inputNode = data.nodes.find(n => n.id === input.ref);
271
+ const label = inputNode?.label || input.ref;
272
+ parts.push(`- **${label}** (${input.role})`);
273
+ }
274
+ parts.push("");
275
+ }
276
+ if (structured.fields && structured.fields.length > 0) {
277
+ parts.push("## Required fields");
278
+ parts.push(structured.fields.map(f => `\`${f}\``).join(", "));
279
+ parts.push("");
280
+ }
281
+ }
224
282
  const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
225
283
  return { content: [{ type: "text", text }] };
226
284
  });
227
285
  // --- Tool 4: get_relationships ---
228
- server.tool("get_relationships", "Get the upstream (what drives it) and downstream (what it drives) relationships for a metric from the GASP relationship graph.", { metric: z.string().describe("Metric name or ID (e.g. 'arr', 'NRR', 'pipeline', 'churn_rate')") }, async ({ metric: name }) => {
286
+ server.tool("get_relationships", "Get the upstream (what drives it) and downstream (what it drives) relationships for a metric, entity, or concept from the GASP relationship graph. Entity nodes show business model connections (OWNS, GRANTS, CONVERTS_TO, etc.).", { metric: z.string().describe("Metric name or ID (e.g. 'arr', 'NRR'), entity (e.g. 'customer', 'subscription'), or concept (e.g. 'revenue', 'bookings')") }, async ({ metric: name }) => {
229
287
  const { node, upstream, downstream } = getRelationships(data, name);
230
288
  if (!node) {
231
289
  const available = data.nodes.map((n) => `${n.label} (${n.id})`).join(", ");
@@ -355,6 +413,241 @@ server.tool("search", "Search across all GASP Standard metrics and definitions.
355
413
  }
356
414
  return { content: [{ type: "text", text: parts.join("\n") }] };
357
415
  });
416
+ // --- Tool 7: generate_query ---
417
+ server.tool("generate_query", "Generate a SQL query for a GASP metric using your adapter mapping. Takes a metric name and adapter JSON, fills in parameterized SQL templates with your warehouse column names.", {
418
+ metric: z
419
+ .string()
420
+ .describe("Metric name to generate SQL for (e.g. 'NRR', 'CAC', 'Pipeline Value', 'ARR')"),
421
+ adapter: z
422
+ .string()
423
+ .describe("Your GASP adapter JSON string with field mappings"),
424
+ }, async ({ metric: metricName, adapter: adapterJson }) => {
425
+ if (!data.queryTemplates) {
426
+ return {
427
+ content: [{ type: "text", text: "Query templates not available. Update the GASP Standard data." }],
428
+ };
429
+ }
430
+ // Parse adapter
431
+ let adapter;
432
+ try {
433
+ adapter = JSON.parse(adapterJson);
434
+ }
435
+ catch {
436
+ return {
437
+ content: [{ type: "text", text: "Invalid adapter JSON." }],
438
+ };
439
+ }
440
+ if (!adapter.mappings || !Array.isArray(adapter.mappings)) {
441
+ return {
442
+ content: [{ type: "text", text: 'Adapter must have a "mappings" array.' }],
443
+ };
444
+ }
445
+ // Find matching template (fuzzy match on metric name)
446
+ const q = metricName.toLowerCase();
447
+ let templateKey;
448
+ let template;
449
+ // Exact match first
450
+ for (const [key, tmpl] of Object.entries(data.queryTemplates)) {
451
+ if (key.toLowerCase() === q) {
452
+ templateKey = key;
453
+ template = tmpl;
454
+ break;
455
+ }
456
+ }
457
+ // Partial match
458
+ if (!template) {
459
+ for (const [key, tmpl] of Object.entries(data.queryTemplates)) {
460
+ if (key.toLowerCase().includes(q) || q.includes(key.toLowerCase())) {
461
+ templateKey = key;
462
+ template = tmpl;
463
+ break;
464
+ }
465
+ }
466
+ }
467
+ if (!template || !templateKey) {
468
+ const available = Object.keys(data.queryTemplates).slice(0, 20).join(", ");
469
+ return {
470
+ content: [{
471
+ type: "text",
472
+ text: `No query template found for "${metricName}".\n\nAvailable templates: ${available}...`,
473
+ }],
474
+ };
475
+ }
476
+ // Build field → warehouseRef map from adapter
477
+ const fieldMap = new Map();
478
+ for (const m of adapter.mappings) {
479
+ if (m.gaspField && m.warehouseRef && m.warehouseRef.trim() !== "") {
480
+ fieldMap.set(m.gaspField, { ref: m.warehouseRef, transform: m.transform });
481
+ }
482
+ }
483
+ // Check which template fields are mapped
484
+ const mapped = [];
485
+ const missing = [];
486
+ for (const field of template.fields) {
487
+ if (fieldMap.has(field))
488
+ mapped.push(field);
489
+ else
490
+ missing.push(field);
491
+ }
492
+ // Fill in the template
493
+ let sql = template.parameterizedQuery;
494
+ // Replace {{field}} placeholders with warehouse refs
495
+ for (const [field, mapping] of fieldMap) {
496
+ const placeholder = `{{${field}}}`;
497
+ if (sql.includes(placeholder)) {
498
+ // Extract just the column name (last part of schema.table.column)
499
+ const parts = mapping.ref.split(".");
500
+ const column = parts[parts.length - 1];
501
+ const replacement = mapping.transform
502
+ ? mapping.transform.replace(/\{\{value\}\}/g, column)
503
+ : column;
504
+ sql = sql.split(placeholder).join(replacement);
505
+ }
506
+ }
507
+ // Replace {{_table}} with the most common table from mapped fields
508
+ if (sql.includes("{{_table}}")) {
509
+ const tableCounts = new Map();
510
+ for (const field of template.fields) {
511
+ const m = fieldMap.get(field);
512
+ if (m) {
513
+ const parts = m.ref.split(".");
514
+ // Table is everything except the last segment (column)
515
+ const table = parts.slice(0, -1).join(".");
516
+ if (table)
517
+ tableCounts.set(table, (tableCounts.get(table) || 0) + 1);
518
+ }
519
+ }
520
+ // Pick the most-referenced table
521
+ let bestTable = "your_table";
522
+ let bestCount = 0;
523
+ for (const [table, count] of tableCounts) {
524
+ if (count > bestCount) {
525
+ bestTable = table;
526
+ bestCount = count;
527
+ }
528
+ }
529
+ sql = sql.split("{{_table}}").join(bestTable);
530
+ }
531
+ // Mark any remaining unfilled placeholders
532
+ const unfilled = sql.match(/\{\{[^}]+\}\}/g);
533
+ if (unfilled) {
534
+ sql = sql + "\n\n-- NOTE: Unmapped fields (fill in your adapter): " + [...new Set(unfilled)].join(", ");
535
+ }
536
+ // Build response
537
+ const parts = [
538
+ `# ${templateKey} — Generated SQL\n`,
539
+ `**Warehouse:** ${adapter.warehouse || "not specified"}`,
540
+ `**Source:** ${template.source}`,
541
+ `**Grain:** ${template.grain}`,
542
+ `**Fields mapped:** ${mapped.length} / ${template.fields.length}\n`,
543
+ ];
544
+ if (missing.length > 0) {
545
+ parts.push(`**Missing fields:** \`${missing.join("`, `")}\`\n`);
546
+ }
547
+ parts.push("```sql\n" + sql + "\n```");
548
+ if (mapped.length > 0) {
549
+ parts.push("\n**Field mapping used:**");
550
+ for (const field of mapped) {
551
+ const m = fieldMap.get(field);
552
+ const line = `- \`${field}\` → \`${m.ref}\`${m.transform ? ` (transform: ${m.transform})` : ""}`;
553
+ parts.push(line);
554
+ }
555
+ }
556
+ return { content: [{ type: "text", text: parts.join("\n") }] };
557
+ });
558
+ // --- Tool 8: validate_adapter ---
559
+ server.tool("validate_adapter", "Validate a GASP adapter mapping file. Takes a JSON string of your adapter and reports which GASP metrics are fully covered, partially covered, or missing based on your mapped fields.", {
560
+ adapter: z
561
+ .string()
562
+ .describe("The adapter JSON as a string. Must follow the GASP adapter schema with version, organization, warehouse, and mappings array."),
563
+ }, async ({ adapter: adapterJson }) => {
564
+ // Parse the adapter
565
+ let adapter;
566
+ try {
567
+ adapter = JSON.parse(adapterJson);
568
+ }
569
+ catch (e) {
570
+ return {
571
+ content: [{ type: "text", text: "Invalid JSON. The adapter must be a valid JSON object." }],
572
+ };
573
+ }
574
+ if (!adapter.mappings || !Array.isArray(adapter.mappings)) {
575
+ return {
576
+ content: [{ type: "text", text: 'Adapter must have a "mappings" array. Download the template from https://gaspwiki.com/downloads/gasp-adapter-template.json' }],
577
+ };
578
+ }
579
+ // Build set of mapped fields (only those with a non-empty warehouseRef)
580
+ const mappedFields = new Set();
581
+ for (const m of adapter.mappings) {
582
+ if (m.gaspField && m.warehouseRef && m.warehouseRef.trim() !== "") {
583
+ mappedFields.add(m.gaspField);
584
+ }
585
+ }
586
+ // Check each data requirement
587
+ const drs = data.dataRequirements || [];
588
+ const fullyCovered = [];
589
+ const partiallyCovered = [];
590
+ const notCovered = [];
591
+ for (const dr of drs) {
592
+ const mapped = dr.fields.filter((f) => mappedFields.has(f));
593
+ const missing = dr.fields.filter((f) => !mappedFields.has(f));
594
+ if (missing.length === 0) {
595
+ fullyCovered.push(dr.metric);
596
+ }
597
+ else if (mapped.length > 0) {
598
+ partiallyCovered.push({ metric: dr.metric, mapped, missing });
599
+ }
600
+ else {
601
+ notCovered.push(dr.metric);
602
+ }
603
+ }
604
+ // Find unmapped GASP fields
605
+ const allGaspFields = new Set();
606
+ for (const dr of drs) {
607
+ for (const f of dr.fields)
608
+ allGaspFields.add(f);
609
+ }
610
+ const unmappedFields = [...allGaspFields].filter((f) => !mappedFields.has(f)).sort();
611
+ // Build report
612
+ const org = adapter.organization || "Unknown";
613
+ const wh = adapter.warehouse || "not specified";
614
+ const parts = [
615
+ `# GASP Adapter Validation — ${org}\n`,
616
+ `**Warehouse:** ${wh}`,
617
+ `**Mapped fields:** ${mappedFields.size} / ${allGaspFields.size} (${Math.round((mappedFields.size / allGaspFields.size) * 100)}%)\n`,
618
+ ];
619
+ if (fullyCovered.length > 0) {
620
+ parts.push(`## Fully Covered Metrics (${fullyCovered.length})`);
621
+ parts.push("These metrics have all required fields mapped and can be computed:\n");
622
+ for (const m of fullyCovered)
623
+ parts.push(`- ${m}`);
624
+ parts.push("");
625
+ }
626
+ if (partiallyCovered.length > 0) {
627
+ parts.push(`## Partially Covered Metrics (${partiallyCovered.length})`);
628
+ parts.push("These metrics are missing some fields:\n");
629
+ for (const p of partiallyCovered) {
630
+ parts.push(`- **${p.metric}** — missing: \`${p.missing.join("`, `")}\``);
631
+ }
632
+ parts.push("");
633
+ }
634
+ if (notCovered.length > 0) {
635
+ parts.push(`## Not Covered (${notCovered.length})`);
636
+ parts.push("No fields mapped for these metrics:\n");
637
+ for (const m of notCovered)
638
+ parts.push(`- ${m}`);
639
+ parts.push("");
640
+ }
641
+ if (unmappedFields.length > 0 && unmappedFields.length <= 30) {
642
+ parts.push(`## Unmapped Fields (${unmappedFields.length})`);
643
+ parts.push(`\`${unmappedFields.join("`, `")}\``);
644
+ }
645
+ else if (unmappedFields.length > 30) {
646
+ parts.push(`## Unmapped Fields (${unmappedFields.length})`);
647
+ parts.push(`First 30: \`${unmappedFields.slice(0, 30).join("`, `")}\` ...`);
648
+ }
649
+ return { content: [{ type: "text", text: parts.join("\n") }] };
650
+ });
358
651
  // Start the server
359
652
  async function main() {
360
653
  const transport = new StdioServerTransport();
package/build/loader.js CHANGED
@@ -97,7 +97,76 @@ export function loadStandard() {
97
97
  }
98
98
  }
99
99
  }
100
- return { metrics, definitions, nodes, edges, dataRequirements };
100
+ // Load query templates
101
+ const queryTemplatePath = join(standardDir, "query-templates.json");
102
+ let queryTemplates;
103
+ try {
104
+ const qtRaw = JSON.parse(readFileSync(queryTemplatePath, "utf-8"));
105
+ queryTemplates = qtRaw.templates || {};
106
+ }
107
+ catch {
108
+ // query-templates.json not yet created
109
+ }
110
+ // Load ontology
111
+ const ontologyPath = join(standardDir, "ontology.json");
112
+ let ontology;
113
+ try {
114
+ const ontRaw = JSON.parse(readFileSync(ontologyPath, "utf-8"));
115
+ ontology = {
116
+ entities: ontRaw.entities || [],
117
+ entityMappings: ontRaw.entityMappings || {},
118
+ concepts: ontRaw.concepts || [],
119
+ entityRelationships: ontRaw.entityRelationships || undefined,
120
+ structuredFormulas: ontRaw.structuredFormulas || undefined,
121
+ };
122
+ }
123
+ catch {
124
+ // ontology.json not yet created
125
+ }
126
+ // Enrich relationship nodes with descriptions
127
+ const defBySlug = new Map();
128
+ for (const m of metrics) {
129
+ if (m.slug && m.definition)
130
+ defBySlug.set(m.slug, m.definition);
131
+ }
132
+ for (const d of definitions) {
133
+ if (d.slug && d.definition && !defBySlug.has(d.slug))
134
+ defBySlug.set(d.slug, d.definition);
135
+ }
136
+ for (const node of nodes) {
137
+ if (node.nodeType === 'entity' && ontology) {
138
+ const entityId = node.id.replace('entity_', '');
139
+ const entity = ontology.entities.find(e => e.id === entityId);
140
+ node.nodeType = 'entity';
141
+ node.description = entity?.definition || '';
142
+ node.category = 'entity';
143
+ }
144
+ else if (node.nodeType === 'concept' && ontology?.concepts) {
145
+ const conceptId = node.id.replace('concept_', '');
146
+ const concept = ontology.concepts.find(c => c.id === conceptId);
147
+ node.nodeType = 'concept';
148
+ node.description = concept?.definition || '';
149
+ node.category = 'concept';
150
+ }
151
+ else {
152
+ // Try to find metric description by slug
153
+ node.department = node.department || '';
154
+ node.signalType = node.signalType || '';
155
+ node.category = node.category || node.department || '';
156
+ if (!node.description) {
157
+ // Build slug from the metric-links mapping pattern
158
+ const slug = node.id.replace(/_/g, '-');
159
+ // Search for a matching definition
160
+ for (const [key, def] of defBySlug) {
161
+ if (key.includes(slug) || slug.includes(key.split('-')[0])) {
162
+ node.description = def;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ return { metrics, definitions, nodes, edges, dataRequirements, ontology, queryTemplates };
101
170
  }
102
171
  /**
103
172
  * Search metrics + definitions for a query string.