@dcluttr/dclare-mcp 0.1.5 → 0.1.7
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/dist/index.js +7 -3
- package/dist/services/query-guardrails.js +22 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -133,9 +133,12 @@ server.registerTool("get_dataset_context", {
|
|
|
133
133
|
server.registerTool("run_semantic_query", {
|
|
134
134
|
title: "Run semantic query",
|
|
135
135
|
description: "Runs a semantic query against the data platform with guardrails. " +
|
|
136
|
-
"IMPORTANT:
|
|
136
|
+
"IMPORTANT RULES: " +
|
|
137
|
+
"1. TIME DIMENSIONS: For any query involving a date range or daily/weekly/monthly trend, you MUST use the timeDimensions parameter with the dataset's time column (usually 'created_at'). Use dateRange for filtering (e.g. 'this month', 'last 7 days', ['2026-02-01','2026-02-28']) and granularity ('day','week','month') for time-series breakdowns. " +
|
|
138
|
+
"2. BRAND FILTER: Brand-specific datasets contain both the user's brand AND competitor data. " +
|
|
137
139
|
"When the user asks about their own brand's performance, include the 'curr_brand' segment to filter to their brand only. " +
|
|
138
|
-
"When the user asks for competitor analysis or market-wide comparison, do NOT include 'curr_brand' so all brands are returned."
|
|
140
|
+
"When the user asks for competitor analysis or market-wide comparison, do NOT include 'curr_brand' so all brands are returned. " +
|
|
141
|
+
"3. MEMBER NAMES: Use plain column names (e.g. 'created_at', 'offtake_mrp') without the dataset prefix.",
|
|
139
142
|
inputSchema: {
|
|
140
143
|
tenantToken: z.string().optional(),
|
|
141
144
|
query: z.object({
|
|
@@ -168,7 +171,8 @@ server.registerTool("ask_data_question", {
|
|
|
168
171
|
"IMPORTANT: Always call search_datasets first to resolve the exact dataset name and pass it as datasetHint. Do NOT guess dataset names. " +
|
|
169
172
|
"Brand-specific datasets contain both the user's brand AND competitor data. " +
|
|
170
173
|
"When the question is about the user's own brand, the plan should include the 'curr_brand' segment. " +
|
|
171
|
-
"When the question involves competitor analysis or market-wide data, do NOT include 'curr_brand'."
|
|
174
|
+
"When the question involves competitor analysis or market-wide data, do NOT include 'curr_brand'. " +
|
|
175
|
+
"For any question involving dates, trends, or time periods, the plan MUST include timeDimensions with the correct dateRange and granularity.",
|
|
172
176
|
inputSchema: {
|
|
173
177
|
tenantToken: z.string().optional(),
|
|
174
178
|
question: z.string().min(5),
|
|
@@ -17,12 +17,19 @@ function buildJoinedCubeMap(dataset, allDatasets) {
|
|
|
17
17
|
}
|
|
18
18
|
return map;
|
|
19
19
|
}
|
|
20
|
-
function validateMember(member, allowedLocal, joinedCubes, label) {
|
|
20
|
+
function validateMember(member, allowedLocal, joinedCubes, label, currentCubeName) {
|
|
21
21
|
if (isJoinedMember(member)) {
|
|
22
22
|
const dotIndex = member.indexOf(".");
|
|
23
23
|
const cubeName = member.slice(0, dotIndex);
|
|
24
24
|
const columnName = member.slice(dotIndex + 1);
|
|
25
25
|
const stripped = normalizeMemberName(columnName);
|
|
26
|
+
// If the prefix matches the current cube, treat as a local member
|
|
27
|
+
if (currentCubeName && cubeName === currentCubeName) {
|
|
28
|
+
if (allowedLocal.has(stripped)) {
|
|
29
|
+
return stripped;
|
|
30
|
+
}
|
|
31
|
+
throw new AppError("INVALID_QUERY", `${label} '${member}' is not allowed for this dataset.`, 400);
|
|
32
|
+
}
|
|
26
33
|
const joinedColumns = joinedCubes.get(cubeName);
|
|
27
34
|
if (joinedColumns && joinedColumns.has(stripped)) {
|
|
28
35
|
return `${cubeName}.${stripped}`;
|
|
@@ -41,7 +48,7 @@ export class QueryGuardrails {
|
|
|
41
48
|
const allowedMetrics = new Set(dataset.metrics.map((metric) => metric.name));
|
|
42
49
|
const allowedSegments = new Set((dataset.segments ?? []).map((segment) => segment.name));
|
|
43
50
|
const joinedCubes = buildJoinedCubeMap(dataset, allDatasets ?? []);
|
|
44
|
-
const validatedDimensions = (input.dimensions ?? []).map((d) => validateMember(d, allowedColumns, joinedCubes, "Dimension"));
|
|
51
|
+
const validatedDimensions = (input.dimensions ?? []).map((d) => validateMember(d, allowedColumns, joinedCubes, "Dimension", dataset.cubeName));
|
|
45
52
|
for (const metric of input.metrics ?? []) {
|
|
46
53
|
const normalized = normalizeMemberName(metric);
|
|
47
54
|
if (!allowedMetrics.has(normalized)) {
|
|
@@ -50,15 +57,23 @@ export class QueryGuardrails {
|
|
|
50
57
|
}
|
|
51
58
|
for (const segment of input.segments ?? []) {
|
|
52
59
|
const normalized = normalizeMemberName(segment);
|
|
60
|
+
// Strip prefix if it matches current cube name
|
|
61
|
+
if (isJoinedMember(segment)) {
|
|
62
|
+
const dotIndex = segment.indexOf(".");
|
|
63
|
+
const cubeName = segment.slice(0, dotIndex);
|
|
64
|
+
if (cubeName !== dataset.cubeName) {
|
|
65
|
+
throw new AppError("INVALID_QUERY", `Segment '${segment}' is not allowed for this dataset.`, 400);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
53
68
|
if (!allowedSegments.has(normalized)) {
|
|
54
69
|
throw new AppError("INVALID_QUERY", `Segment '${segment}' is not allowed for this dataset.`, 400);
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
const validatedTimeDimensions = (input.timeDimensions ?? []).map((item) => {
|
|
58
|
-
const resolved = validateMember(item.member, allowedColumns, joinedCubes, "Time dimension");
|
|
73
|
+
const resolved = validateMember(item.member, allowedColumns, joinedCubes, "Time dimension", dataset.cubeName);
|
|
59
74
|
return { ...item, member: resolved };
|
|
60
75
|
});
|
|
61
|
-
const validFilters = this.validateFilters(input.filters ?? [], allowedColumns, joinedCubes);
|
|
76
|
+
const validFilters = this.validateFilters(input.filters ?? [], allowedColumns, joinedCubes, dataset.cubeName);
|
|
62
77
|
const allFilters = [...validFilters];
|
|
63
78
|
const order = this.validateOrder(input.order ?? [], allowedColumns, allowedMetrics, joinedCubes, dataset.cubeName);
|
|
64
79
|
const normalizedSegments = (input.segments ?? []).map((segment) => normalizeMemberName(segment));
|
|
@@ -81,9 +96,9 @@ export class QueryGuardrails {
|
|
|
81
96
|
limit
|
|
82
97
|
};
|
|
83
98
|
}
|
|
84
|
-
validateFilters(filters, allowedColumns, joinedCubes) {
|
|
99
|
+
validateFilters(filters, allowedColumns, joinedCubes, currentCubeName) {
|
|
85
100
|
return filters.map((filter) => {
|
|
86
|
-
const resolved = validateMember(filter.member, allowedColumns, joinedCubes, "Filter member");
|
|
101
|
+
const resolved = validateMember(filter.member, allowedColumns, joinedCubes, "Filter member", currentCubeName);
|
|
87
102
|
if (!Array.isArray(filter.values) || filter.values.length === 0) {
|
|
88
103
|
throw new AppError("INVALID_QUERY", `Filter '${filter.member}' must include at least one value.`, 400);
|
|
89
104
|
}
|
|
@@ -98,7 +113,7 @@ export class QueryGuardrails {
|
|
|
98
113
|
for (const item of order) {
|
|
99
114
|
let member;
|
|
100
115
|
try {
|
|
101
|
-
member = validateMember(item.member, allowedColumns, joinedCubes, "Order member");
|
|
116
|
+
member = validateMember(item.member, allowedColumns, joinedCubes, "Order member", cubeName);
|
|
102
117
|
}
|
|
103
118
|
catch {
|
|
104
119
|
const normalized = normalizeMemberName(item.member);
|