@lightdash-tools/mcp 0.2.6 → 0.3.1
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 +17 -2
- package/dist/audit.d.ts +6 -0
- package/dist/audit.js +11 -0
- package/dist/bin.js +13 -1
- package/dist/config.d.ts +23 -0
- package/dist/config.js +43 -0
- package/dist/http.js +2 -0
- package/dist/index.js +6 -4
- package/dist/tools/ai-agents.d.ts +6 -0
- package/dist/tools/ai-agents.js +388 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +107 -4
- package/dist/tools/shared.test.js +120 -7
- package/package.json +3 -3
- package/src/audit.ts +6 -0
- package/src/bin.ts +22 -2
- package/src/config.ts +43 -1
- package/src/http.ts +4 -1
- package/src/index.ts +5 -2
- package/src/tools/ai-agents.ts +759 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/shared.test.ts +214 -13
- package/src/tools/shared.ts +135 -8
package/dist/tools/shared.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types and helpers for MCP tool registration.
|
|
3
|
+
*
|
|
4
|
+
* Guardrail layers applied by registerToolSafe (outer → inner):
|
|
5
|
+
* 1. Audit log wrapper — captures timing and outcome for every call.
|
|
6
|
+
* 2. Project allowlist — rejects calls targeting disallowed project UUIDs at runtime.
|
|
7
|
+
* 3. Dry-run wrapper — simulates writes without executing them (registration-time).
|
|
8
|
+
* 4. Safety-mode wrapper — disables tools that exceed the configured safety level.
|
|
9
|
+
* 5. Raw handler — the actual tool implementation.
|
|
3
10
|
*/
|
|
4
11
|
import type { LightdashClient } from '@lightdash-tools/client';
|
|
5
12
|
import type { ToolAnnotations } from '@lightdash-tools/common';
|
|
@@ -23,6 +30,12 @@ export type ToolOptions = {
|
|
|
23
30
|
annotations?: ToolAnnotations;
|
|
24
31
|
};
|
|
25
32
|
export { READ_ONLY_DEFAULT, WRITE_IDEMPOTENT, WRITE_DESTRUCTIVE } from '@lightdash-tools/common';
|
|
26
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Registers a tool with prefix and annotations, applying all guardrail layers.
|
|
35
|
+
* shortName is prefixed to become TOOL_PREFIX + shortName.
|
|
36
|
+
* Pass annotations explicitly (e.g. READ_ONLY_DEFAULT, WRITE_IDEMPOTENT, or WRITE_DESTRUCTIVE).
|
|
37
|
+
*
|
|
38
|
+
* CLI flag --allowed-projects always takes priority over LIGHTDASH_TOOLS_ALLOWED_PROJECTS.
|
|
39
|
+
*/
|
|
27
40
|
export declare function registerToolSafe(server: unknown, shortName: string, options: ToolOptions, handler: ToolHandler): void;
|
|
28
41
|
export declare function wrapTool<T>(client: LightdashClient, fn: (client: LightdashClient) => (args: T) => Promise<TextContent>): ToolHandler;
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Shared types and helpers for MCP tool registration.
|
|
4
|
+
*
|
|
5
|
+
* Guardrail layers applied by registerToolSafe (outer → inner):
|
|
6
|
+
* 1. Audit log wrapper — captures timing and outcome for every call.
|
|
7
|
+
* 2. Project allowlist — rejects calls targeting disallowed project UUIDs at runtime.
|
|
8
|
+
* 3. Dry-run wrapper — simulates writes without executing them (registration-time).
|
|
9
|
+
* 4. Safety-mode wrapper — disables tools that exceed the configured safety level.
|
|
10
|
+
* 5. Raw handler — the actual tool implementation.
|
|
4
11
|
*/
|
|
5
12
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
6
13
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
@@ -31,20 +38,33 @@ const DEFAULT_ANNOTATIONS = common_1.READ_ONLY_DEFAULT;
|
|
|
31
38
|
function mergeAnnotations(overrides) {
|
|
32
39
|
return Object.assign(Object.assign({}, DEFAULT_ANNOTATIONS), overrides);
|
|
33
40
|
}
|
|
34
|
-
|
|
41
|
+
function isGuardrailBlocked(result) {
|
|
42
|
+
return ('_lightdashBlocked' in result &&
|
|
43
|
+
result['_lightdashBlocked'] === true);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Registers a tool with prefix and annotations, applying all guardrail layers.
|
|
47
|
+
* shortName is prefixed to become TOOL_PREFIX + shortName.
|
|
48
|
+
* Pass annotations explicitly (e.g. READ_ONLY_DEFAULT, WRITE_IDEMPOTENT, or WRITE_DESTRUCTIVE).
|
|
49
|
+
*
|
|
50
|
+
* CLI flag --allowed-projects always takes priority over LIGHTDASH_TOOLS_ALLOWED_PROJECTS.
|
|
51
|
+
*/
|
|
35
52
|
function registerToolSafe(server, shortName, options, handler) {
|
|
36
53
|
var _a, _b;
|
|
37
54
|
const name = exports.TOOL_PREFIX + shortName;
|
|
38
55
|
const annotations = mergeAnnotations(options.annotations);
|
|
39
|
-
// Static Filtering
|
|
56
|
+
// ── Static Filtering ──────────────────────────────────────────────────────
|
|
57
|
+
// Skip registration entirely if the tool exceeds the static safety mode.
|
|
40
58
|
const staticMode = (0, config_js_1.getStaticSafetyMode)();
|
|
41
59
|
if (staticMode && !(0, common_1.isAllowed)(staticMode, annotations)) {
|
|
42
60
|
return;
|
|
43
61
|
}
|
|
44
|
-
//
|
|
62
|
+
// ── Safety-mode wrapper ───────────────────────────────────────────────────
|
|
63
|
+
// Tool is registered but calls are rejected at runtime when the dynamic mode
|
|
64
|
+
// does not permit the operation.
|
|
45
65
|
const mode = (0, config_js_1.getSafetyMode)();
|
|
46
66
|
const isToolAllowed = (0, common_1.isAllowed)(mode, annotations);
|
|
47
|
-
|
|
67
|
+
const isReadOnly = !!annotations.readOnlyHint;
|
|
48
68
|
let finalHandler = handler;
|
|
49
69
|
let finalDescription = options.description;
|
|
50
70
|
if (!isToolAllowed) {
|
|
@@ -58,9 +78,92 @@ function registerToolSafe(server, shortName, options, handler) {
|
|
|
58
78
|
},
|
|
59
79
|
],
|
|
60
80
|
isError: true,
|
|
81
|
+
_lightdashBlocked: true,
|
|
61
82
|
});
|
|
62
83
|
});
|
|
63
84
|
}
|
|
85
|
+
else if ((0, config_js_1.isDryRunMode)() && !isReadOnly) {
|
|
86
|
+
// ── Dry-run wrapper ─────────────────────────────────────────────────────
|
|
87
|
+
// Write operations are simulated; no API calls are made.
|
|
88
|
+
finalDescription = `[DRY-RUN] ${options.description}`;
|
|
89
|
+
finalHandler = (args) => __awaiter(this, void 0, void 0, function* () {
|
|
90
|
+
return ({
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: `[DRY-RUN] Tool '${name}' would be called with: ${JSON.stringify(args, null, 2)}. No changes were made.`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
_lightdashBlocked: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// ── Project allowlist wrapper ─────────────────────────────────────────────
|
|
102
|
+
// Reject calls targeting project UUIDs not in the configured allowlist.
|
|
103
|
+
// Covers both singular (projectUuid) and plural (projectUuids[]) arg shapes.
|
|
104
|
+
// CLI --allowed-projects takes priority over LIGHTDASH_TOOLS_ALLOWED_PROJECTS.
|
|
105
|
+
const allowedProjects = (0, config_js_1.getAllowedProjectUuids)();
|
|
106
|
+
if (allowedProjects.length > 0) {
|
|
107
|
+
const innerHandler = finalHandler;
|
|
108
|
+
finalHandler = (args, extra) => __awaiter(this, void 0, void 0, function* () {
|
|
109
|
+
const projectUuids = (0, common_1.extractProjectUuids)(args);
|
|
110
|
+
const deniedUuids = projectUuids.filter((uuid) => !(0, common_1.areAllProjectsAllowed)(allowedProjects, [uuid]));
|
|
111
|
+
if (deniedUuids.length > 0) {
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
type: 'text',
|
|
116
|
+
text: `Error: Project(s) [${deniedUuids.join(', ')}] are not in the list of allowed projects. Allowed: [${allowedProjects.join(', ')}].`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
isError: true,
|
|
120
|
+
_lightdashBlocked: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return innerHandler(args, extra);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// ── Audit log wrapper ─────────────────────────────────────────────────────
|
|
127
|
+
// Outermost layer: records timing and outcome for every call.
|
|
128
|
+
const auditedInner = finalHandler;
|
|
129
|
+
finalHandler = (args, extra) => __awaiter(this, void 0, void 0, function* () {
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
const projectUuids = (0, common_1.extractProjectUuids)(args);
|
|
132
|
+
let status = 'success';
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = yield auditedInner(args, extra);
|
|
136
|
+
if (isGuardrailBlocked(result)) {
|
|
137
|
+
status = 'blocked';
|
|
138
|
+
}
|
|
139
|
+
else if (result.isError) {
|
|
140
|
+
status = 'error';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
status = 'error';
|
|
145
|
+
(0, common_1.logAuditEntry)({
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
sessionId: (0, common_1.getSessionId)(),
|
|
148
|
+
tool: name,
|
|
149
|
+
projectUuids: projectUuids.length > 0 ? projectUuids : undefined,
|
|
150
|
+
status,
|
|
151
|
+
durationMs: Date.now() - start,
|
|
152
|
+
});
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
(0, common_1.logAuditEntry)({
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
sessionId: (0, common_1.getSessionId)(),
|
|
158
|
+
tool: name,
|
|
159
|
+
projectUuids: projectUuids.length > 0 ? projectUuids : undefined,
|
|
160
|
+
status,
|
|
161
|
+
durationMs: Date.now() - start,
|
|
162
|
+
});
|
|
163
|
+
// Strip the internal marker before returning to the MCP client.
|
|
164
|
+
const { content, isError } = result;
|
|
165
|
+
return { content, isError };
|
|
166
|
+
});
|
|
64
167
|
const mergedOptions = Object.assign(Object.assign({}, options), { description: finalDescription, title: (_a = options.title) !== null && _a !== void 0 ? _a : (_b = options.annotations) === null || _b === void 0 ? void 0 : _b.title, annotations });
|
|
65
168
|
server.registerTool(name, mergedOptions, finalHandler);
|
|
66
169
|
}
|
|
@@ -13,13 +13,34 @@ const vitest_1 = require("vitest");
|
|
|
13
13
|
const shared_1 = require("./shared");
|
|
14
14
|
const common_1 = require("@lightdash-tools/common");
|
|
15
15
|
const config_js_1 = require("../config.js");
|
|
16
|
+
// Silence audit log output during tests
|
|
17
|
+
vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0, void 0, void 0, function* () {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
const actual = yield importOriginal();
|
|
20
|
+
return Object.assign(Object.assign({}, actual), { getSessionId: () => 'test-session', logAuditEntry: vitest_1.vi.fn(), initAuditLog: vitest_1.vi.fn() });
|
|
21
|
+
}));
|
|
16
22
|
(0, vitest_1.describe)('registerToolSafe', () => {
|
|
17
23
|
const mockServer = {
|
|
18
24
|
registerTool: vitest_1.vi.fn(),
|
|
19
25
|
};
|
|
20
26
|
const mockHandler = vitest_1.vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'success' }] });
|
|
27
|
+
(0, vitest_1.beforeEach)(() => {
|
|
28
|
+
mockServer.registerTool.mockClear();
|
|
29
|
+
mockHandler.mockClear();
|
|
30
|
+
// Reset globals to safe defaults
|
|
31
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
32
|
+
(0, config_js_1.setStaticAllowedProjectUuids)([]);
|
|
33
|
+
(0, config_js_1.setDryRunMode)(false);
|
|
34
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
35
|
+
delete process.env.LIGHTDASH_TOOLS_ALLOWED_PROJECTS;
|
|
36
|
+
delete process.env.LIGHTDASH_DRY_RUN;
|
|
37
|
+
});
|
|
38
|
+
(0, vitest_1.afterEach)(() => {
|
|
39
|
+
delete process.env.LIGHTDASH_TOOL_SAFETY_MODE;
|
|
40
|
+
});
|
|
21
41
|
(0, vitest_1.it)('should allow read-only tool in read-only mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
22
42
|
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
|
|
43
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE); // static = allow all
|
|
23
44
|
(0, shared_1.registerToolSafe)(mockServer, 'test_tool', {
|
|
24
45
|
description: 'Test description',
|
|
25
46
|
inputSchema: {},
|
|
@@ -34,12 +55,13 @@ const config_js_1 = require("../config.js");
|
|
|
34
55
|
}));
|
|
35
56
|
(0, vitest_1.it)('should block destructive tool in read-only mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
36
57
|
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
|
|
58
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
37
59
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_tool', {
|
|
38
60
|
description: 'Delete something',
|
|
39
61
|
inputSchema: {},
|
|
40
62
|
annotations: shared_1.WRITE_DESTRUCTIVE,
|
|
41
63
|
}, mockHandler);
|
|
42
|
-
const [, options, handler] = mockServer.registerTool.mock.calls[
|
|
64
|
+
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
43
65
|
(0, vitest_1.expect)(options.description).toContain('[DISABLED in read-only mode]');
|
|
44
66
|
const result = yield handler({});
|
|
45
67
|
(0, vitest_1.expect)(result.isError).toBe(true);
|
|
@@ -47,19 +69,19 @@ const config_js_1 = require("../config.js");
|
|
|
47
69
|
}));
|
|
48
70
|
(0, vitest_1.it)('should allow destructive tool in write-destructive mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
49
71
|
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
72
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
50
73
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_tool_2', {
|
|
51
74
|
description: 'Delete something 2',
|
|
52
75
|
inputSchema: {},
|
|
53
76
|
annotations: shared_1.WRITE_DESTRUCTIVE,
|
|
54
77
|
}, mockHandler);
|
|
55
|
-
const [, options, handler] = mockServer.registerTool.mock.calls[
|
|
78
|
+
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
56
79
|
(0, vitest_1.expect)(options.description).toBe('Delete something 2');
|
|
57
80
|
const result = yield handler({});
|
|
58
81
|
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
59
82
|
}));
|
|
60
83
|
(0, vitest_1.describe)('static filtering (safety-mode)', () => {
|
|
61
84
|
(0, vitest_1.it)('should skip registration if tool is more permissive than binded mode', () => {
|
|
62
|
-
// Set binded mode to READ_ONLY
|
|
63
85
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.READ_ONLY);
|
|
64
86
|
mockServer.registerTool.mockClear();
|
|
65
87
|
(0, shared_1.registerToolSafe)(mockServer, 'destructive_tool_static', {
|
|
@@ -79,10 +101,7 @@ const config_js_1 = require("../config.js");
|
|
|
79
101
|
}, mockHandler);
|
|
80
102
|
(0, vitest_1.expect)(mockServer.registerTool).toHaveBeenCalled();
|
|
81
103
|
});
|
|
82
|
-
(0, vitest_1.it)('should allow everything if binded mode is
|
|
83
|
-
// This is a bit tricky since it's a global. We might need a way to reset it.
|
|
84
|
-
// For now, let's assume we can just pass a permissive mode or it was undefined initially.
|
|
85
|
-
// Since we don't have a reset, let's just test that it works when set to DESTRUCTIVE.
|
|
104
|
+
(0, vitest_1.it)('should allow everything if binded mode is write-destructive', () => {
|
|
86
105
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
87
106
|
mockServer.registerTool.mockClear();
|
|
88
107
|
(0, shared_1.registerToolSafe)(mockServer, 'any_tool_static', {
|
|
@@ -93,4 +112,98 @@ const config_js_1 = require("../config.js");
|
|
|
93
112
|
(0, vitest_1.expect)(mockServer.registerTool).toHaveBeenCalled();
|
|
94
113
|
});
|
|
95
114
|
});
|
|
115
|
+
(0, vitest_1.describe)('project UUID allowlist', () => {
|
|
116
|
+
(0, vitest_1.it)('should allow calls when allowlist is empty (all projects permitted)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
117
|
+
(0, config_js_1.setStaticAllowedProjectUuids)([]);
|
|
118
|
+
(0, shared_1.registerToolSafe)(mockServer, 'list_charts', { description: 'List charts', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
119
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
120
|
+
const result = yield handler({ projectUuid: 'any-uuid' });
|
|
121
|
+
(0, vitest_1.expect)(result.isError).toBeUndefined();
|
|
122
|
+
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
123
|
+
}));
|
|
124
|
+
(0, vitest_1.it)('should allow calls for a singular projectUuid in the allowlist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
125
|
+
(0, config_js_1.setStaticAllowedProjectUuids)(['uuid-allowed', 'uuid-other']);
|
|
126
|
+
(0, shared_1.registerToolSafe)(mockServer, 'list_charts_allowed', { description: 'List charts', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
127
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
128
|
+
const result = yield handler({ projectUuid: 'uuid-allowed' });
|
|
129
|
+
(0, vitest_1.expect)(result.isError).toBeUndefined();
|
|
130
|
+
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
131
|
+
}));
|
|
132
|
+
(0, vitest_1.it)('should block calls for a singular projectUuid not in the allowlist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
133
|
+
(0, config_js_1.setStaticAllowedProjectUuids)(['uuid-allowed']);
|
|
134
|
+
(0, shared_1.registerToolSafe)(mockServer, 'list_charts_blocked', { description: 'List charts', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
135
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
136
|
+
const result = yield handler({ projectUuid: 'uuid-denied' });
|
|
137
|
+
(0, vitest_1.expect)(result.isError).toBe(true);
|
|
138
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('not in the list of allowed projects');
|
|
139
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('uuid-denied');
|
|
140
|
+
}));
|
|
141
|
+
(0, vitest_1.it)('should allow calls with no projectUuid arg even when allowlist is set', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
142
|
+
(0, config_js_1.setStaticAllowedProjectUuids)(['uuid-allowed']);
|
|
143
|
+
(0, shared_1.registerToolSafe)(mockServer, 'list_projects_no_uuid', { description: 'List projects', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
144
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
145
|
+
// No projectUuid in args → allowlist does not apply
|
|
146
|
+
const result = yield handler({});
|
|
147
|
+
(0, vitest_1.expect)(result.isError).toBeUndefined();
|
|
148
|
+
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
149
|
+
}));
|
|
150
|
+
(0, vitest_1.it)('should allow when all projectUuids[] are in the allowlist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
151
|
+
(0, config_js_1.setStaticAllowedProjectUuids)(['uuid-a', 'uuid-b']);
|
|
152
|
+
(0, shared_1.registerToolSafe)(mockServer, 'search_content_allowed', { description: 'Search content', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
153
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
154
|
+
const result = yield handler({ projectUuids: ['uuid-a', 'uuid-b'] });
|
|
155
|
+
(0, vitest_1.expect)(result.isError).toBeUndefined();
|
|
156
|
+
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
157
|
+
}));
|
|
158
|
+
(0, vitest_1.it)('should block when any UUID in projectUuids[] is not in the allowlist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
159
|
+
(0, config_js_1.setStaticAllowedProjectUuids)(['uuid-a']);
|
|
160
|
+
(0, shared_1.registerToolSafe)(mockServer, 'search_content_blocked', { description: 'Search content', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
161
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
162
|
+
const result = yield handler({ projectUuids: ['uuid-a', 'uuid-denied'] });
|
|
163
|
+
(0, vitest_1.expect)(result.isError).toBe(true);
|
|
164
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('uuid-denied');
|
|
165
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('not in the list of allowed projects');
|
|
166
|
+
}));
|
|
167
|
+
(0, vitest_1.it)('should block when all projectUuids[] are outside the allowlist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
168
|
+
(0, config_js_1.setStaticAllowedProjectUuids)(['uuid-allowed']);
|
|
169
|
+
(0, shared_1.registerToolSafe)(mockServer, 'search_content_all_blocked', { description: 'Search content', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
170
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
171
|
+
const result = yield handler({ projectUuids: ['uuid-x', 'uuid-y'] });
|
|
172
|
+
(0, vitest_1.expect)(result.isError).toBe(true);
|
|
173
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('not in the list of allowed projects');
|
|
174
|
+
}));
|
|
175
|
+
});
|
|
176
|
+
(0, vitest_1.describe)('dry-run mode', () => {
|
|
177
|
+
(0, vitest_1.it)('should not affect read-only tools in dry-run mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
178
|
+
(0, config_js_1.setDryRunMode)(true);
|
|
179
|
+
(0, shared_1.registerToolSafe)(mockServer, 'list_things_dry', { description: 'List things', inputSchema: {}, annotations: shared_1.READ_ONLY_DEFAULT }, mockHandler);
|
|
180
|
+
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
181
|
+
(0, vitest_1.expect)(options.description).not.toContain('[DRY-RUN]');
|
|
182
|
+
const result = yield handler({});
|
|
183
|
+
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
184
|
+
}));
|
|
185
|
+
(0, vitest_1.it)('should simulate write-idempotent tools in dry-run mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
186
|
+
(0, config_js_1.setDryRunMode)(true);
|
|
187
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
188
|
+
(0, shared_1.registerToolSafe)(mockServer, 'upsert_thing_dry', { description: 'Upsert thing', inputSchema: {}, annotations: shared_1.WRITE_IDEMPOTENT }, mockHandler);
|
|
189
|
+
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
190
|
+
(0, vitest_1.expect)(options.description).toContain('[DRY-RUN]');
|
|
191
|
+
const result = yield handler({ projectUuid: 'uuid-x', slug: 'my-chart' });
|
|
192
|
+
(0, vitest_1.expect)(result.isError).toBeUndefined();
|
|
193
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('[DRY-RUN]');
|
|
194
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('No changes were made');
|
|
195
|
+
// Verify the underlying handler was NOT called
|
|
196
|
+
(0, vitest_1.expect)(mockHandler).not.toHaveBeenCalled();
|
|
197
|
+
}));
|
|
198
|
+
(0, vitest_1.it)('should simulate destructive tools in dry-run mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
199
|
+
(0, config_js_1.setDryRunMode)(true);
|
|
200
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
201
|
+
(0, shared_1.registerToolSafe)(mockServer, 'delete_thing_dry', { description: 'Delete thing', inputSchema: {}, annotations: shared_1.WRITE_DESTRUCTIVE }, mockHandler);
|
|
202
|
+
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
203
|
+
(0, vitest_1.expect)(options.description).toContain('[DRY-RUN]');
|
|
204
|
+
const result = yield handler({ projectUuid: 'uuid-x' });
|
|
205
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('No changes were made');
|
|
206
|
+
(0, vitest_1.expect)(mockHandler).not.toHaveBeenCalled();
|
|
207
|
+
}));
|
|
208
|
+
});
|
|
96
209
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash-tools/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server and utilities for Lightdash AI.",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
15
15
|
"commander": "^14.0.3",
|
|
16
16
|
"zod": "^4.3.6",
|
|
17
|
-
"@lightdash-tools/
|
|
18
|
-
"@lightdash-tools/
|
|
17
|
+
"@lightdash-tools/client": "0.3.1",
|
|
18
|
+
"@lightdash-tools/common": "0.3.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/node": "^25.2.3"
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exports the shared audit logger from @lightdash-tools/common.
|
|
3
|
+
* The canonical implementation lives in common so the CLI can also use it.
|
|
4
|
+
*/
|
|
5
|
+
export { getSessionId, initAuditLog, logAuditEntry } from '@lightdash-tools/common';
|
|
6
|
+
export type { AuditLogEntry, AuditStatus } from '@lightdash-tools/common';
|
package/src/bin.ts
CHANGED
|
@@ -5,19 +5,27 @@
|
|
|
5
5
|
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
import { SafetyMode } from '@lightdash-tools/common';
|
|
8
|
-
import { setStaticSafetyMode } from './config.js';
|
|
8
|
+
import { setStaticSafetyMode, setStaticAllowedProjectUuids, setDryRunMode } from './config.js';
|
|
9
9
|
|
|
10
10
|
const program = new Command();
|
|
11
11
|
|
|
12
12
|
program
|
|
13
13
|
.name('lightdash-mcp')
|
|
14
14
|
.description('MCP server for Lightdash AI')
|
|
15
|
-
.version('0.
|
|
15
|
+
.version('0.3.1')
|
|
16
16
|
.option('--http', 'Run as HTTP server instead of Stdio')
|
|
17
17
|
.option(
|
|
18
18
|
'--safety-mode <mode>',
|
|
19
19
|
'Filter registered tools by safety mode (read-only, write-idempotent, write-destructive)',
|
|
20
20
|
)
|
|
21
|
+
.option(
|
|
22
|
+
'--projects <uuids>',
|
|
23
|
+
'Comma-separated list of allowed project UUIDs (overrides LIGHTDASH_ALLOWED_PROJECTS; empty = all allowed)',
|
|
24
|
+
)
|
|
25
|
+
.option(
|
|
26
|
+
'--dry-run',
|
|
27
|
+
'Simulate write operations without executing them (overrides LIGHTDASH_DRY_RUN)',
|
|
28
|
+
)
|
|
21
29
|
.action((options) => {
|
|
22
30
|
if (options.safetyMode) {
|
|
23
31
|
if (Object.values(SafetyMode).includes(options.safetyMode)) {
|
|
@@ -28,6 +36,18 @@ program
|
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
if (options.projects) {
|
|
40
|
+
const uuids = (options.projects as string)
|
|
41
|
+
.split(',')
|
|
42
|
+
.map((s) => s.trim())
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
setStaticAllowedProjectUuids(uuids);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.dryRun) {
|
|
48
|
+
setDryRunMode(true);
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
if (options.http) {
|
|
32
52
|
void import('./http.js');
|
|
33
53
|
} else {
|
package/src/config.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { LightdashClient, mergeConfig } from '@lightdash-tools/client';
|
|
7
7
|
import type { PartialLightdashClientConfig } from '@lightdash-tools/client';
|
|
8
|
-
import { getSafetyModeFromEnv } from '@lightdash-tools/common';
|
|
8
|
+
import { getSafetyModeFromEnv, getAllowedProjectUuidsFromEnv } from '@lightdash-tools/common';
|
|
9
9
|
import type { SafetyMode } from '@lightdash-tools/common';
|
|
10
10
|
|
|
11
11
|
let globalStaticSafetyMode: SafetyMode | undefined;
|
|
12
|
+
let globalStaticAllowedProjectUuids: string[] | undefined;
|
|
13
|
+
let globalDryRunMode: boolean | undefined;
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Gets the safety mode for dynamic enforcement.
|
|
@@ -31,6 +33,46 @@ export function setStaticSafetyMode(mode: SafetyMode): void {
|
|
|
31
33
|
globalStaticSafetyMode = mode;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Returns the effective project UUID allowlist.
|
|
38
|
+
* CLI-provided values override the environment variable.
|
|
39
|
+
* An empty array means all projects are allowed.
|
|
40
|
+
*/
|
|
41
|
+
export function getAllowedProjectUuids(): string[] {
|
|
42
|
+
return globalStaticAllowedProjectUuids ?? getAllowedProjectUuidsFromEnv();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sets the project UUID allowlist from the CLI (overrides LIGHTDASH_ALLOWED_PROJECTS).
|
|
47
|
+
*/
|
|
48
|
+
export function setStaticAllowedProjectUuids(uuids: string[]): void {
|
|
49
|
+
globalStaticAllowedProjectUuids = uuids;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns true when dry-run mode is active.
|
|
54
|
+
* CLI flag overrides the LIGHTDASH_DRY_RUN environment variable.
|
|
55
|
+
*/
|
|
56
|
+
export function isDryRunMode(): boolean {
|
|
57
|
+
if (globalDryRunMode !== undefined) return globalDryRunMode;
|
|
58
|
+
const v = process.env.LIGHTDASH_DRY_RUN;
|
|
59
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Enables or disables dry-run mode (from CLI).
|
|
64
|
+
*/
|
|
65
|
+
export function setDryRunMode(enabled: boolean): void {
|
|
66
|
+
globalDryRunMode = enabled;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the audit log file path from LIGHTDASH_AUDIT_LOG, or undefined to use stderr.
|
|
71
|
+
*/
|
|
72
|
+
export function getAuditLogPath(): string | undefined {
|
|
73
|
+
return process.env.LIGHTDASH_AUDIT_LOG || undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
34
76
|
/**
|
|
35
77
|
* Builds a LightdashClient from environment variables (and optional overrides).
|
|
36
78
|
* Throws if LIGHTDASH_URL or LIGHTDASH_API_KEY are missing.
|
package/src/http.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
|
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
9
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
10
|
-
import { getClient } from './config.js';
|
|
10
|
+
import { getClient, getAuditLogPath } from './config.js';
|
|
11
|
+
import { initAuditLog } from './audit.js';
|
|
11
12
|
import { registerTools } from './tools/index.js';
|
|
12
13
|
|
|
13
14
|
const MCP_PATH = '/mcp';
|
|
@@ -165,6 +166,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
function main(): void {
|
|
169
|
+
initAuditLog(getAuditLogPath());
|
|
170
|
+
|
|
168
171
|
const server = createServer((req, res) => {
|
|
169
172
|
handleRequest(req, res).catch((err) => {
|
|
170
173
|
console.error('MCP HTTP handler error:', err);
|
package/src/index.ts
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
-
import { getClient } from './config';
|
|
9
|
-
import {
|
|
8
|
+
import { getClient, getAuditLogPath } from './config.js';
|
|
9
|
+
import { initAuditLog } from './audit.js';
|
|
10
|
+
import { registerTools } from './tools/index.js';
|
|
10
11
|
|
|
11
12
|
async function main(): Promise<void> {
|
|
13
|
+
initAuditLog(getAuditLogPath());
|
|
14
|
+
|
|
12
15
|
const client = getClient();
|
|
13
16
|
const server = new McpServer({
|
|
14
17
|
name: 'lightdash-mcp',
|