@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 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: Brand-specific datasets contain both the user's brand AND competitor data. " +
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcluttr/dclare-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "MCP server for secure talk-to-data on Cube + ClickHouse",
6
6
  "bin": {