gasp-standard-mcp 1.3.0 → 1.4.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
@@ -1,6 +1,6 @@
1
1
  # gasp-standard-mcp
2
2
 
3
- MCP server for the [GASP Standard](https://gaspwiki.com) — the Generally Accepted SaaS Performance standard for metric definitions, formulas, benchmarks, and relationships.
3
+ MCP server for the [GASP Standard](https://gaspwiki.com) — the Generally Accepted SaaS Principles standard for metric definitions, formulas, benchmarks, and relationships.
4
4
 
5
5
  Works with Claude Code, Claude Desktop, or any [MCP](https://modelcontextprotocol.io/)-compatible client.
6
6
 
@@ -33,19 +33,24 @@ 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
- - 299 metrics across 13 departments
46
- - 79 glossary definitions
47
- - 22 relationship nodes, 34 edges
48
- - 75 data requirements with field types, grain, and example SQL
47
+ - 300 metrics across 13 departments
48
+ - 80 glossary definitions
49
+ - 76 relationship nodes (60 metrics + 11 entities + 5 concepts), 149 edges
50
+ - 15 entity-to-entity business model edges (OWNS, GRANTS, CONVERTS_TO, GENERATES, etc.)
51
+ - 78 data requirements with field types, grain, and example SQL
52
+ - 11 business entities, 5 business concepts, 22 source categories, 197 canonical fields
53
+ - 60 temporal mappings (grain, frequency, period for every metric node)
49
54
  - Sources: the canonical [GASP Standard](https://gaspwiki.com) files
50
55
 
51
56
  ## Examples
@@ -55,6 +60,7 @@ Once installed, ask your AI assistant:
55
60
  - "What is NRR?"
56
61
  - "How do I calculate CAC Payback?"
57
62
  - "What drives ARR?"
63
+ - "How does Customer relate to Subscription?"
58
64
  - "List all marketing metrics"
59
65
  - "What data do I need to implement ARR tracking?"
60
66
  - "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;
@@ -225,7 +244,7 @@ server.tool("get_formula", "Get the formula(s) for a GASP Standard metric. Retur
225
244
  return { content: [{ type: "text", text }] };
226
245
  });
227
246
  // --- 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 }) => {
247
+ 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
248
  const { node, upstream, downstream } = getRelationships(data, name);
230
249
  if (!node) {
231
250
  const available = data.nodes.map((n) => `${n.label} (${n.id})`).join(", ");
@@ -355,6 +374,241 @@ server.tool("search", "Search across all GASP Standard metrics and definitions.
355
374
  }
356
375
  return { content: [{ type: "text", text: parts.join("\n") }] };
357
376
  });
377
+ // --- Tool 7: generate_query ---
378
+ 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.", {
379
+ metric: z
380
+ .string()
381
+ .describe("Metric name to generate SQL for (e.g. 'NRR', 'CAC', 'Pipeline Value', 'ARR')"),
382
+ adapter: z
383
+ .string()
384
+ .describe("Your GASP adapter JSON string with field mappings"),
385
+ }, async ({ metric: metricName, adapter: adapterJson }) => {
386
+ if (!data.queryTemplates) {
387
+ return {
388
+ content: [{ type: "text", text: "Query templates not available. Update the GASP Standard data." }],
389
+ };
390
+ }
391
+ // Parse adapter
392
+ let adapter;
393
+ try {
394
+ adapter = JSON.parse(adapterJson);
395
+ }
396
+ catch {
397
+ return {
398
+ content: [{ type: "text", text: "Invalid adapter JSON." }],
399
+ };
400
+ }
401
+ if (!adapter.mappings || !Array.isArray(adapter.mappings)) {
402
+ return {
403
+ content: [{ type: "text", text: 'Adapter must have a "mappings" array.' }],
404
+ };
405
+ }
406
+ // Find matching template (fuzzy match on metric name)
407
+ const q = metricName.toLowerCase();
408
+ let templateKey;
409
+ let template;
410
+ // Exact match first
411
+ for (const [key, tmpl] of Object.entries(data.queryTemplates)) {
412
+ if (key.toLowerCase() === q) {
413
+ templateKey = key;
414
+ template = tmpl;
415
+ break;
416
+ }
417
+ }
418
+ // Partial match
419
+ if (!template) {
420
+ for (const [key, tmpl] of Object.entries(data.queryTemplates)) {
421
+ if (key.toLowerCase().includes(q) || q.includes(key.toLowerCase())) {
422
+ templateKey = key;
423
+ template = tmpl;
424
+ break;
425
+ }
426
+ }
427
+ }
428
+ if (!template || !templateKey) {
429
+ const available = Object.keys(data.queryTemplates).slice(0, 20).join(", ");
430
+ return {
431
+ content: [{
432
+ type: "text",
433
+ text: `No query template found for "${metricName}".\n\nAvailable templates: ${available}...`,
434
+ }],
435
+ };
436
+ }
437
+ // Build field → warehouseRef map from adapter
438
+ const fieldMap = new Map();
439
+ for (const m of adapter.mappings) {
440
+ if (m.gaspField && m.warehouseRef && m.warehouseRef.trim() !== "") {
441
+ fieldMap.set(m.gaspField, { ref: m.warehouseRef, transform: m.transform });
442
+ }
443
+ }
444
+ // Check which template fields are mapped
445
+ const mapped = [];
446
+ const missing = [];
447
+ for (const field of template.fields) {
448
+ if (fieldMap.has(field))
449
+ mapped.push(field);
450
+ else
451
+ missing.push(field);
452
+ }
453
+ // Fill in the template
454
+ let sql = template.parameterizedQuery;
455
+ // Replace {{field}} placeholders with warehouse refs
456
+ for (const [field, mapping] of fieldMap) {
457
+ const placeholder = `{{${field}}}`;
458
+ if (sql.includes(placeholder)) {
459
+ // Extract just the column name (last part of schema.table.column)
460
+ const parts = mapping.ref.split(".");
461
+ const column = parts[parts.length - 1];
462
+ const replacement = mapping.transform
463
+ ? mapping.transform.replace(/\{\{value\}\}/g, column)
464
+ : column;
465
+ sql = sql.split(placeholder).join(replacement);
466
+ }
467
+ }
468
+ // Replace {{_table}} with the most common table from mapped fields
469
+ if (sql.includes("{{_table}}")) {
470
+ const tableCounts = new Map();
471
+ for (const field of template.fields) {
472
+ const m = fieldMap.get(field);
473
+ if (m) {
474
+ const parts = m.ref.split(".");
475
+ // Table is everything except the last segment (column)
476
+ const table = parts.slice(0, -1).join(".");
477
+ if (table)
478
+ tableCounts.set(table, (tableCounts.get(table) || 0) + 1);
479
+ }
480
+ }
481
+ // Pick the most-referenced table
482
+ let bestTable = "your_table";
483
+ let bestCount = 0;
484
+ for (const [table, count] of tableCounts) {
485
+ if (count > bestCount) {
486
+ bestTable = table;
487
+ bestCount = count;
488
+ }
489
+ }
490
+ sql = sql.split("{{_table}}").join(bestTable);
491
+ }
492
+ // Mark any remaining unfilled placeholders
493
+ const unfilled = sql.match(/\{\{[^}]+\}\}/g);
494
+ if (unfilled) {
495
+ sql = sql + "\n\n-- NOTE: Unmapped fields (fill in your adapter): " + [...new Set(unfilled)].join(", ");
496
+ }
497
+ // Build response
498
+ const parts = [
499
+ `# ${templateKey} — Generated SQL\n`,
500
+ `**Warehouse:** ${adapter.warehouse || "not specified"}`,
501
+ `**Source:** ${template.source}`,
502
+ `**Grain:** ${template.grain}`,
503
+ `**Fields mapped:** ${mapped.length} / ${template.fields.length}\n`,
504
+ ];
505
+ if (missing.length > 0) {
506
+ parts.push(`**Missing fields:** \`${missing.join("`, `")}\`\n`);
507
+ }
508
+ parts.push("```sql\n" + sql + "\n```");
509
+ if (mapped.length > 0) {
510
+ parts.push("\n**Field mapping used:**");
511
+ for (const field of mapped) {
512
+ const m = fieldMap.get(field);
513
+ const line = `- \`${field}\` → \`${m.ref}\`${m.transform ? ` (transform: ${m.transform})` : ""}`;
514
+ parts.push(line);
515
+ }
516
+ }
517
+ return { content: [{ type: "text", text: parts.join("\n") }] };
518
+ });
519
+ // --- Tool 8: validate_adapter ---
520
+ 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.", {
521
+ adapter: z
522
+ .string()
523
+ .describe("The adapter JSON as a string. Must follow the GASP adapter schema with version, organization, warehouse, and mappings array."),
524
+ }, async ({ adapter: adapterJson }) => {
525
+ // Parse the adapter
526
+ let adapter;
527
+ try {
528
+ adapter = JSON.parse(adapterJson);
529
+ }
530
+ catch (e) {
531
+ return {
532
+ content: [{ type: "text", text: "Invalid JSON. The adapter must be a valid JSON object." }],
533
+ };
534
+ }
535
+ if (!adapter.mappings || !Array.isArray(adapter.mappings)) {
536
+ return {
537
+ content: [{ type: "text", text: 'Adapter must have a "mappings" array. Download the template from https://gaspwiki.com/downloads/gasp-adapter-template.json' }],
538
+ };
539
+ }
540
+ // Build set of mapped fields (only those with a non-empty warehouseRef)
541
+ const mappedFields = new Set();
542
+ for (const m of adapter.mappings) {
543
+ if (m.gaspField && m.warehouseRef && m.warehouseRef.trim() !== "") {
544
+ mappedFields.add(m.gaspField);
545
+ }
546
+ }
547
+ // Check each data requirement
548
+ const drs = data.dataRequirements || [];
549
+ const fullyCovered = [];
550
+ const partiallyCovered = [];
551
+ const notCovered = [];
552
+ for (const dr of drs) {
553
+ const mapped = dr.fields.filter((f) => mappedFields.has(f));
554
+ const missing = dr.fields.filter((f) => !mappedFields.has(f));
555
+ if (missing.length === 0) {
556
+ fullyCovered.push(dr.metric);
557
+ }
558
+ else if (mapped.length > 0) {
559
+ partiallyCovered.push({ metric: dr.metric, mapped, missing });
560
+ }
561
+ else {
562
+ notCovered.push(dr.metric);
563
+ }
564
+ }
565
+ // Find unmapped GASP fields
566
+ const allGaspFields = new Set();
567
+ for (const dr of drs) {
568
+ for (const f of dr.fields)
569
+ allGaspFields.add(f);
570
+ }
571
+ const unmappedFields = [...allGaspFields].filter((f) => !mappedFields.has(f)).sort();
572
+ // Build report
573
+ const org = adapter.organization || "Unknown";
574
+ const wh = adapter.warehouse || "not specified";
575
+ const parts = [
576
+ `# GASP Adapter Validation — ${org}\n`,
577
+ `**Warehouse:** ${wh}`,
578
+ `**Mapped fields:** ${mappedFields.size} / ${allGaspFields.size} (${Math.round((mappedFields.size / allGaspFields.size) * 100)}%)\n`,
579
+ ];
580
+ if (fullyCovered.length > 0) {
581
+ parts.push(`## Fully Covered Metrics (${fullyCovered.length})`);
582
+ parts.push("These metrics have all required fields mapped and can be computed:\n");
583
+ for (const m of fullyCovered)
584
+ parts.push(`- ${m}`);
585
+ parts.push("");
586
+ }
587
+ if (partiallyCovered.length > 0) {
588
+ parts.push(`## Partially Covered Metrics (${partiallyCovered.length})`);
589
+ parts.push("These metrics are missing some fields:\n");
590
+ for (const p of partiallyCovered) {
591
+ parts.push(`- **${p.metric}** — missing: \`${p.missing.join("`, `")}\``);
592
+ }
593
+ parts.push("");
594
+ }
595
+ if (notCovered.length > 0) {
596
+ parts.push(`## Not Covered (${notCovered.length})`);
597
+ parts.push("No fields mapped for these metrics:\n");
598
+ for (const m of notCovered)
599
+ parts.push(`- ${m}`);
600
+ parts.push("");
601
+ }
602
+ if (unmappedFields.length > 0 && unmappedFields.length <= 30) {
603
+ parts.push(`## Unmapped Fields (${unmappedFields.length})`);
604
+ parts.push(`\`${unmappedFields.join("`, `")}\``);
605
+ }
606
+ else if (unmappedFields.length > 30) {
607
+ parts.push(`## Unmapped Fields (${unmappedFields.length})`);
608
+ parts.push(`First 30: \`${unmappedFields.slice(0, 30).join("`, `")}\` ...`);
609
+ }
610
+ return { content: [{ type: "text", text: parts.join("\n") }] };
611
+ });
358
612
  // Start the server
359
613
  async function main() {
360
614
  const transport = new StdioServerTransport();
package/build/loader.js CHANGED
@@ -97,7 +97,75 @@ 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
+ };
121
+ }
122
+ catch {
123
+ // ontology.json not yet created
124
+ }
125
+ // Enrich relationship nodes with descriptions
126
+ const defBySlug = new Map();
127
+ for (const m of metrics) {
128
+ if (m.slug && m.definition)
129
+ defBySlug.set(m.slug, m.definition);
130
+ }
131
+ for (const d of definitions) {
132
+ if (d.slug && d.definition && !defBySlug.has(d.slug))
133
+ defBySlug.set(d.slug, d.definition);
134
+ }
135
+ for (const node of nodes) {
136
+ if (node.nodeType === 'entity' && ontology) {
137
+ const entityId = node.id.replace('entity_', '');
138
+ const entity = ontology.entities.find(e => e.id === entityId);
139
+ node.nodeType = 'entity';
140
+ node.description = entity?.definition || '';
141
+ node.category = 'entity';
142
+ }
143
+ else if (node.nodeType === 'concept' && ontology?.concepts) {
144
+ const conceptId = node.id.replace('concept_', '');
145
+ const concept = ontology.concepts.find(c => c.id === conceptId);
146
+ node.nodeType = 'concept';
147
+ node.description = concept?.definition || '';
148
+ node.category = 'concept';
149
+ }
150
+ else {
151
+ // Try to find metric description by slug
152
+ node.department = node.department || '';
153
+ node.signalType = node.signalType || '';
154
+ node.category = node.category || node.department || '';
155
+ if (!node.description) {
156
+ // Build slug from the metric-links mapping pattern
157
+ const slug = node.id.replace(/_/g, '-');
158
+ // Search for a matching definition
159
+ for (const [key, def] of defBySlug) {
160
+ if (key.includes(slug) || slug.includes(key.split('-')[0])) {
161
+ node.description = def;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return { metrics, definitions, nodes, edges, dataRequirements, ontology, queryTemplates };
101
169
  }
102
170
  /**
103
171
  * Search metrics + definitions for a query string.