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 +18 -3
- package/build/index.js +232 -43
- package/build/loader.d.ts +16 -3
- package/build/loader.js +50 -12
- package/build/parser.js +11 -0
- package/build/standard-data.json +1 -1
- package/build/standard-version.json +1 -1
- package/build/traversal.d.ts +78 -0
- package/build/traversal.js +320 -0
- package/build/types.d.ts +17 -0
- package/build/urls.d.ts +20 -0
- package/build/urls.js +31 -0
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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} (
|
|
170
|
-
`- **Scope:** ${ac.accessScope} (
|
|
171
|
-
`See
|
|
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 =
|
|
269
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
*
|
|
225
|
-
*
|
|
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;
|