gasp-standard-mcp 1.3.1 → 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 +10 -4
- package/build/index.js +255 -1
- package/build/loader.js +69 -1
- package/build/standard-data.json +1 -1
- package/build/standard-version.json +1 -1
- package/build/types.d.ts +52 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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.)
|
|
48
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', '
|
|
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
|
-
|
|
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.
|