@dcluttr/dclare-mcp 0.1.2

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.
@@ -0,0 +1,131 @@
1
+ import { env } from "../config/env.js";
2
+ import { plannerOutputSchema } from "../types/planner.js";
3
+ import { AppError } from "../utils/errors.js";
4
+ import { tryParseJsonObject } from "../utils/json.js";
5
+ export class PlannerService {
6
+ metadataStore;
7
+ constructor(metadataStore) {
8
+ this.metadataStore = metadataStore;
9
+ }
10
+ async plan(input) {
11
+ if (env.PLANNER_PROVIDER === "openai") {
12
+ const modelPlan = await this.planWithOpenAI(input);
13
+ return plannerOutputSchema.parse(modelPlan);
14
+ }
15
+ return this.planHeuristic(input);
16
+ }
17
+ planHeuristic(input) {
18
+ const datasets = this.metadataStore.getCatalog().datasets;
19
+ const dataset = input.datasetHint
20
+ ? this.metadataStore.getDataset(input.datasetHint)
21
+ : this.pickHeuristicDataset(input.question, datasets) ?? datasets[0];
22
+ if (!dataset) {
23
+ throw new AppError("PLANNER_ERROR", "No dataset available in metadata catalog.", 400);
24
+ }
25
+ const metric = dataset.metrics[0]?.name;
26
+ const timeDimension = dataset.timeDimension;
27
+ const tenantSegment = dataset.tenantEnforcement?.mode === "segment" ? dataset.tenantEnforcement.segmentName ?? "curr_brand" : undefined;
28
+ const semanticQuery = {
29
+ dataset: dataset.name,
30
+ metrics: metric ? [metric] : undefined,
31
+ dimensions: [],
32
+ segments: tenantSegment ? [tenantSegment] : undefined,
33
+ filters: [],
34
+ timeDimensions: timeDimension
35
+ ? [
36
+ {
37
+ member: timeDimension,
38
+ dateRange: "last 30 days",
39
+ granularity: "day"
40
+ }
41
+ ]
42
+ : undefined,
43
+ limit: input.limitHint
44
+ };
45
+ return plannerOutputSchema.parse({
46
+ semanticQuery,
47
+ rationale: "Heuristic planner selected dataset by name match and default metric/time pattern.",
48
+ assumptions: ["Question intent is trend/summary oriented", "Use default 30-day horizon when timeframe is omitted"],
49
+ confidence: 0.55
50
+ });
51
+ }
52
+ matchesDataset(question, datasetName) {
53
+ return question.toLowerCase().includes(datasetName.toLowerCase());
54
+ }
55
+ pickHeuristicDataset(question, datasets) {
56
+ const q = question.toLowerCase();
57
+ const platformKeys = ["blinkit", "instamart", "zepto", "bbnow", "fkmin", "amazonnow"];
58
+ for (const platform of platformKeys) {
59
+ if (q.includes(platform)) {
60
+ const preferred = `${platform}_date_insights`;
61
+ const dataset = datasets.find((item) => item.name === preferred);
62
+ if (dataset)
63
+ return dataset;
64
+ }
65
+ }
66
+ return datasets.find((item) => this.matchesDataset(question, item.name));
67
+ }
68
+ async planWithOpenAI(input) {
69
+ if (!env.OPENAI_API_KEY) {
70
+ throw new AppError("PLANNER_ERROR", "Query planner is not configured. Please contact support.", 500);
71
+ }
72
+ const catalog = this.metadataStore.getCatalog();
73
+ const prompt = [
74
+ "You are a query planner for Cube semantic queries.",
75
+ "Return JSON only, no markdown.",
76
+ "Respect dataset, metric, and dimension names exactly from catalog.",
77
+ "Planner output JSON schema:",
78
+ JSON.stringify({
79
+ semanticQuery: {
80
+ dataset: "string",
81
+ metrics: ["string"],
82
+ dimensions: ["string"],
83
+ filters: [{ member: "string", operator: "equals", values: ["string"] }],
84
+ order: [{ member: "string", direction: "asc|desc" }],
85
+ timeDimensions: [{ member: "string", dateRange: "last 30 days", granularity: "day" }],
86
+ limit: 1000
87
+ },
88
+ rationale: "string",
89
+ assumptions: ["string"],
90
+ confidence: 0.8
91
+ }, null, 2),
92
+ "Catalog:",
93
+ JSON.stringify(catalog),
94
+ "User question:",
95
+ input.question,
96
+ input.datasetHint ? `Dataset hint: ${input.datasetHint}` : "",
97
+ input.limitHint ? `Limit hint: ${input.limitHint}` : ""
98
+ ]
99
+ .filter(Boolean)
100
+ .join("\n\n");
101
+ const response = await fetch(`${env.OPENAI_BASE_URL}/chat/completions`, {
102
+ method: "POST",
103
+ headers: {
104
+ "Content-Type": "application/json",
105
+ Authorization: `Bearer ${env.OPENAI_API_KEY}`
106
+ },
107
+ body: JSON.stringify({
108
+ model: env.OPENAI_MODEL,
109
+ temperature: 0,
110
+ messages: [
111
+ { role: "system", content: "You output strict JSON only." },
112
+ { role: "user", content: prompt }
113
+ ],
114
+ response_format: { type: "json_object" }
115
+ })
116
+ });
117
+ if (!response.ok) {
118
+ throw new AppError("PLANNER_ERROR", "Failed to generate query plan. Please try again.", 502);
119
+ }
120
+ const payload = (await response.json());
121
+ const content = payload.choices?.[0]?.message?.content;
122
+ if (!content) {
123
+ throw new AppError("PLANNER_ERROR", "Failed to generate query plan. Please try again.", 502);
124
+ }
125
+ const parsed = tryParseJsonObject(content);
126
+ if (!parsed) {
127
+ throw new AppError("PLANNER_ERROR", "Failed to generate query plan. Please try again.", 502);
128
+ }
129
+ return parsed;
130
+ }
131
+ }
@@ -0,0 +1,46 @@
1
+ import { Redis } from "ioredis";
2
+ import { env } from "../config/env.js";
3
+ export class QueryCache {
4
+ redis = null;
5
+ constructor() {
6
+ if (env.REDIS_URL) {
7
+ this.redis = new Redis(env.REDIS_URL, {
8
+ lazyConnect: true,
9
+ maxRetriesPerRequest: 1,
10
+ enableOfflineQueue: false
11
+ });
12
+ }
13
+ }
14
+ isEnabled() {
15
+ return this.redis !== null;
16
+ }
17
+ async get(key) {
18
+ if (!this.redis) {
19
+ return null;
20
+ }
21
+ try {
22
+ if (this.redis.status === "wait") {
23
+ await this.redis.connect();
24
+ }
25
+ const raw = await this.redis.get(key);
26
+ return raw ? JSON.parse(raw) : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ async set(key, value) {
33
+ if (!this.redis) {
34
+ return;
35
+ }
36
+ try {
37
+ if (this.redis.status === "wait") {
38
+ await this.redis.connect();
39
+ }
40
+ await this.redis.set(key, JSON.stringify(value), "EX", env.CACHE_TTL_SECONDS);
41
+ }
42
+ catch {
43
+ // Best-effort cache
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,120 @@
1
+ import { env } from "../config/env.js";
2
+ import { AppError } from "../utils/errors.js";
3
+ function normalizeMemberName(member) {
4
+ const index = member.lastIndexOf(".");
5
+ return index >= 0 ? member.slice(index + 1) : member;
6
+ }
7
+ function isJoinedMember(member) {
8
+ return member.includes(".");
9
+ }
10
+ function buildJoinedCubeMap(dataset, allDatasets) {
11
+ const map = new Map();
12
+ for (const join of dataset.joins ?? []) {
13
+ const joinedDataset = allDatasets.find((d) => d.name === join.cube);
14
+ if (joinedDataset) {
15
+ map.set(join.cube, new Set(joinedDataset.columns.map((c) => c.name)));
16
+ }
17
+ }
18
+ return map;
19
+ }
20
+ function validateMember(member, allowedLocal, joinedCubes, label) {
21
+ if (isJoinedMember(member)) {
22
+ const dotIndex = member.indexOf(".");
23
+ const cubeName = member.slice(0, dotIndex);
24
+ const columnName = member.slice(dotIndex + 1);
25
+ const stripped = normalizeMemberName(columnName);
26
+ const joinedColumns = joinedCubes.get(cubeName);
27
+ if (joinedColumns && joinedColumns.has(stripped)) {
28
+ return `${cubeName}.${stripped}`;
29
+ }
30
+ throw new AppError("INVALID_QUERY", `${label} '${member}' is not allowed for this dataset.`, 400);
31
+ }
32
+ const normalized = normalizeMemberName(member);
33
+ if (allowedLocal.has(normalized)) {
34
+ return normalized;
35
+ }
36
+ throw new AppError("INVALID_QUERY", `${label} '${member}' is not allowed for this dataset.`, 400);
37
+ }
38
+ export class QueryGuardrails {
39
+ sanitizeAndBuildCubeQuery(input, dataset, allDatasets) {
40
+ const allowedColumns = new Set(dataset.columns.map((column) => column.name));
41
+ const allowedMetrics = new Set(dataset.metrics.map((metric) => metric.name));
42
+ const allowedSegments = new Set((dataset.segments ?? []).map((segment) => segment.name));
43
+ const joinedCubes = buildJoinedCubeMap(dataset, allDatasets ?? []);
44
+ const validatedDimensions = (input.dimensions ?? []).map((d) => validateMember(d, allowedColumns, joinedCubes, "Dimension"));
45
+ for (const metric of input.metrics ?? []) {
46
+ const normalized = normalizeMemberName(metric);
47
+ if (!allowedMetrics.has(normalized)) {
48
+ throw new AppError("INVALID_QUERY", `Metric '${metric}' is not allowed for this dataset.`, 400);
49
+ }
50
+ }
51
+ for (const segment of input.segments ?? []) {
52
+ const normalized = normalizeMemberName(segment);
53
+ if (!allowedSegments.has(normalized)) {
54
+ throw new AppError("INVALID_QUERY", `Segment '${segment}' is not allowed for this dataset.`, 400);
55
+ }
56
+ }
57
+ const validatedTimeDimensions = (input.timeDimensions ?? []).map((item) => {
58
+ const resolved = validateMember(item.member, allowedColumns, joinedCubes, "Time dimension");
59
+ return { ...item, member: resolved };
60
+ });
61
+ const validFilters = this.validateFilters(input.filters ?? [], allowedColumns, joinedCubes);
62
+ const allFilters = [...validFilters];
63
+ const order = this.validateOrder(input.order ?? [], allowedColumns, allowedMetrics, joinedCubes, dataset.cubeName);
64
+ const normalizedSegments = (input.segments ?? []).map((segment) => normalizeMemberName(segment));
65
+ const limit = Math.min(Math.max(1, input.limit ?? env.MAX_PREVIEW_ROWS), env.MAX_ROWS);
66
+ return {
67
+ measures: (input.metrics ?? []).map((metric) => this.toMember(dataset.cubeName, normalizeMemberName(metric))),
68
+ dimensions: validatedDimensions.map((d) => (d.includes(".") ? d : this.toMember(dataset.cubeName, d))),
69
+ segments: normalizedSegments.map((segment) => this.toMember(dataset.cubeName, segment)),
70
+ filters: allFilters.map((filter) => ({
71
+ member: filter.member.includes(".") ? filter.member : this.toMember(dataset.cubeName, filter.member),
72
+ operator: filter.operator,
73
+ values: filter.values
74
+ })),
75
+ order,
76
+ timeDimensions: validatedTimeDimensions.map((item) => ({
77
+ dimension: item.member.includes(".") ? item.member : this.toMember(dataset.cubeName, item.member),
78
+ dateRange: item.dateRange,
79
+ granularity: item.granularity
80
+ })),
81
+ limit
82
+ };
83
+ }
84
+ validateFilters(filters, allowedColumns, joinedCubes) {
85
+ return filters.map((filter) => {
86
+ const resolved = validateMember(filter.member, allowedColumns, joinedCubes, "Filter member");
87
+ if (!Array.isArray(filter.values) || filter.values.length === 0) {
88
+ throw new AppError("INVALID_QUERY", `Filter '${filter.member}' must include at least one value.`, 400);
89
+ }
90
+ return {
91
+ ...filter,
92
+ member: resolved
93
+ };
94
+ });
95
+ }
96
+ validateOrder(order, allowedColumns, allowedMetrics, joinedCubes, cubeName) {
97
+ const validated = {};
98
+ for (const item of order) {
99
+ let member;
100
+ try {
101
+ member = validateMember(item.member, allowedColumns, joinedCubes, "Order member");
102
+ }
103
+ catch {
104
+ const normalized = normalizeMemberName(item.member);
105
+ if (allowedMetrics.has(normalized)) {
106
+ member = normalized;
107
+ }
108
+ else {
109
+ throw new AppError("INVALID_QUERY", `Order member '${item.member}' is not allowed.`, 400);
110
+ }
111
+ }
112
+ const qualified = member.includes(".") ? member : this.toMember(cubeName, member);
113
+ validated[qualified] = item.direction;
114
+ }
115
+ return validated;
116
+ }
117
+ toMember(cubeName, member) {
118
+ return member.includes(".") ? member : `${cubeName}.${member}`;
119
+ }
120
+ }
@@ -0,0 +1,84 @@
1
+ import { env } from "../config/env.js";
2
+ import { sha256 } from "../utils/hash.js";
3
+ import { stableStringify } from "../utils/json.js";
4
+ export class QueryService {
5
+ metadataStore;
6
+ guardrails;
7
+ cubeClient;
8
+ profiler;
9
+ cache;
10
+ observability;
11
+ langfuse;
12
+ constructor(metadataStore, guardrails, cubeClient, profiler, cache, observability, langfuse) {
13
+ this.metadataStore = metadataStore;
14
+ this.guardrails = guardrails;
15
+ this.cubeClient = cubeClient;
16
+ this.profiler = profiler;
17
+ this.cache = cache;
18
+ this.observability = observability;
19
+ this.langfuse = langfuse;
20
+ }
21
+ async runQuery(input, tenant, authToken) {
22
+ const dataset = this.metadataStore.getDataset(input.dataset);
23
+ const cubeQuery = this.guardrails.sanitizeAndBuildCubeQuery(input, dataset, this.metadataStore.getCatalog().datasets);
24
+ const cacheKey = this.getCacheKey(tenant.brandId, cubeQuery);
25
+ return await this.observability.span("query.run", {
26
+ "tenant.brand_id": tenant.brandId,
27
+ "query.dataset": dataset.name,
28
+ "query.limit": cubeQuery.limit ?? 0,
29
+ "cache.enabled": this.cache.isEnabled()
30
+ }, async () => {
31
+ const cached = await this.cache.get(cacheKey);
32
+ if (cached) {
33
+ await this.langfuse.capture({
34
+ name: "query.cache.hit",
35
+ input,
36
+ output: {
37
+ dataset: cached.dataset,
38
+ rowCount: cached.rowCount
39
+ },
40
+ metadata: {
41
+ tenant: tenant.brandId,
42
+ cacheKey
43
+ }
44
+ });
45
+ return { ...cached, cache: "hit" };
46
+ }
47
+ const cubeResult = await this.observability.span("cube.load", {
48
+ "tenant.brand_id": tenant.brandId,
49
+ "query.dataset": dataset.name
50
+ }, async () => this.cubeClient.load(cubeQuery, tenant.brandId, authToken));
51
+ const response = this.buildResponse(dataset.name, cubeResult.data ?? [], cubeResult.lastRefreshTime ?? null);
52
+ await this.cache.set(cacheKey, response);
53
+ await this.langfuse.capture({
54
+ name: "query.executed",
55
+ input,
56
+ output: {
57
+ dataset: response.dataset,
58
+ rowCount: response.rowCount,
59
+ cache: "miss"
60
+ },
61
+ metadata: {
62
+ tenant: tenant.brandId,
63
+ cacheKey,
64
+ maxRows: env.MAX_ROWS
65
+ }
66
+ });
67
+ return { ...response, cache: "miss" };
68
+ });
69
+ }
70
+ buildResponse(dataset, rows, lastRefreshTime) {
71
+ const profiled = this.profiler.profile(rows);
72
+ return {
73
+ dataset,
74
+ rowCount: profiled.rowCount,
75
+ previewRows: profiled.previewRows,
76
+ numericSummary: profiled.numericSummary,
77
+ lastRefreshTime
78
+ };
79
+ }
80
+ getCacheKey(brandId, cubeQuery) {
81
+ const hash = sha256(stableStringify({ brandId, cubeQuery }));
82
+ return `${env.CACHE_PREFIX}:${brandId}:${hash}`;
83
+ }
84
+ }
@@ -0,0 +1,53 @@
1
+ import { env } from "../config/env.js";
2
+ export class ResultProfiler {
3
+ stripPrefix(key) {
4
+ const dotIndex = key.indexOf(".");
5
+ return dotIndex >= 0 ? key.slice(dotIndex + 1) : key;
6
+ }
7
+ stripRowKeys(row) {
8
+ const result = {};
9
+ for (const [key, value] of Object.entries(row)) {
10
+ result[this.stripPrefix(key)] = value;
11
+ }
12
+ return result;
13
+ }
14
+ profile(rows) {
15
+ const previewRows = rows.slice(0, env.MAX_PREVIEW_ROWS).map((row) => this.stripRowKeys(row));
16
+ const numericSummary = {};
17
+ if (rows.length === 0) {
18
+ return {
19
+ rowCount: 0,
20
+ previewRows,
21
+ numericSummary
22
+ };
23
+ }
24
+ const firstRow = rows[0];
25
+ if (!firstRow) {
26
+ return {
27
+ rowCount: 0,
28
+ previewRows,
29
+ numericSummary
30
+ };
31
+ }
32
+ const keys = Object.keys(firstRow);
33
+ for (const key of keys) {
34
+ const values = rows
35
+ .map((row) => row[key])
36
+ .filter((value) => typeof value === "number" && Number.isFinite(value));
37
+ if (values.length === 0) {
38
+ continue;
39
+ }
40
+ const sum = values.reduce((acc, value) => acc + value, 0);
41
+ numericSummary[this.stripPrefix(key)] = {
42
+ min: Math.min(...values),
43
+ max: Math.max(...values),
44
+ avg: Number((sum / values.length).toFixed(4))
45
+ };
46
+ }
47
+ return {
48
+ rowCount: rows.length,
49
+ previewRows,
50
+ numericSummary
51
+ };
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ export const queryFilterSchema = z.object({
3
+ member: z.string(),
4
+ operator: z.enum([
5
+ "equals",
6
+ "notEquals",
7
+ "contains",
8
+ "notContains",
9
+ "startsWith",
10
+ "endsWith",
11
+ "gt",
12
+ "gte",
13
+ "lt",
14
+ "lte",
15
+ "inDateRange"
16
+ ]),
17
+ values: z.array(z.string()).min(1)
18
+ });
19
+ export const queryOrderSchema = z.object({
20
+ member: z.string(),
21
+ direction: z.enum(["asc", "desc"])
22
+ });
23
+ export const queryTimeDimensionSchema = z.object({
24
+ member: z.string(),
25
+ dateRange: z.union([z.string(), z.tuple([z.string(), z.string()])]),
26
+ granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).optional()
27
+ });
28
+ export const semanticQuerySchema = z.object({
29
+ dataset: z.string(),
30
+ metrics: z.array(z.string()).optional(),
31
+ dimensions: z.array(z.string()).optional(),
32
+ segments: z.array(z.string()).optional(),
33
+ filters: z.array(queryFilterSchema).optional(),
34
+ order: z.array(queryOrderSchema).optional(),
35
+ timeDimensions: z.array(queryTimeDimensionSchema).optional(),
36
+ limit: z.number().int().positive().optional()
37
+ });
38
+ export const plannerOutputSchema = z.object({
39
+ semanticQuery: semanticQuerySchema,
40
+ rationale: z.string().min(1),
41
+ assumptions: z.array(z.string()).default([]),
42
+ confidence: z.number().min(0).max(1)
43
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ export class AppError extends Error {
2
+ code;
3
+ statusCode;
4
+ constructor(code, message, statusCode = 400) {
5
+ super(message);
6
+ this.code = code;
7
+ this.statusCode = statusCode;
8
+ }
9
+ }
@@ -0,0 +1,4 @@
1
+ import crypto from "node:crypto";
2
+ export function sha256(input) {
3
+ return crypto.createHash("sha256").update(input).digest("hex");
4
+ }
@@ -0,0 +1,27 @@
1
+ export function stableStringify(value) {
2
+ return JSON.stringify(sortRecursively(value));
3
+ }
4
+ function sortRecursively(value) {
5
+ if (Array.isArray(value)) {
6
+ return value.map(sortRecursively);
7
+ }
8
+ if (value && typeof value === "object") {
9
+ const entries = Object.entries(value)
10
+ .sort(([a], [b]) => a.localeCompare(b))
11
+ .map(([key, nested]) => [key, sortRecursively(nested)]);
12
+ return Object.fromEntries(entries);
13
+ }
14
+ return value;
15
+ }
16
+ export function tryParseJsonObject(input) {
17
+ try {
18
+ const parsed = JSON.parse(input);
19
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
20
+ return parsed;
21
+ }
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
@@ -0,0 +1,6 @@
1
+ export function textResult(message, structuredContent) {
2
+ return {
3
+ content: [{ type: "text", text: message }],
4
+ structuredContent
5
+ };
6
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@dcluttr/dclare-mcp",
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "MCP server for secure talk-to-data on Cube + ClickHouse",
6
+ "bin": {
7
+ "dclare-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "context"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20.0.0"
15
+ },
16
+ "scripts": {
17
+ "dev": "tsx src/index.ts",
18
+ "build": "tsc -p tsconfig.json",
19
+ "prepare": "npm run build",
20
+ "start": "node dist/index.js",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.18.1",
25
+ "@opentelemetry/api": "^1.9.0",
26
+ "@opentelemetry/resources": "^1.30.1",
27
+ "@opentelemetry/sdk-trace-base": "^1.30.1",
28
+ "@opentelemetry/semantic-conventions": "^1.30.0",
29
+ "axios": "^1.13.6",
30
+ "ioredis": "^5.7.0",
31
+ "jsonwebtoken": "^9.0.2",
32
+ "zod": "^3.25.76"
33
+ },
34
+ "devDependencies": {
35
+ "@types/jsonwebtoken": "^9.0.10",
36
+ "@types/node": "^24.5.2",
37
+ "tsx": "^4.20.5",
38
+ "typescript": "^5.9.2"
39
+ }
40
+ }