@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.
@@ -16,6 +16,7 @@ import { registerMetricsTools } from './metrics.js';
16
16
  import { registerSchedulersTools } from './schedulers.js';
17
17
  import { registerTagsTools } from './tags.js';
18
18
  import { registerContentTools } from './content.js';
19
+ import { registerAiAgentTools } from './ai-agents.js';
19
20
 
20
21
  export function registerTools(server: McpServer, client: LightdashClient): void {
21
22
  registerProjectTools(server, client);
@@ -30,4 +31,5 @@ export function registerTools(server: McpServer, client: LightdashClient): void
30
31
  registerSchedulersTools(server, client);
31
32
  registerTagsTools(server, client);
32
33
  registerContentTools(server, client);
34
+ registerAiAgentTools(server, client);
33
35
  }
@@ -1,7 +1,19 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { registerToolSafe, READ_ONLY_DEFAULT, WRITE_DESTRUCTIVE } from './shared';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { registerToolSafe, READ_ONLY_DEFAULT, WRITE_DESTRUCTIVE, WRITE_IDEMPOTENT } from './shared';
3
3
  import { SafetyMode } from '@lightdash-tools/common';
4
- import { setStaticSafetyMode } from '../config.js';
4
+ import { setStaticSafetyMode, setStaticAllowedProjectUuids, setDryRunMode } from '../config.js';
5
+
6
+ // Silence audit log output during tests
7
+ vi.mock('@lightdash-tools/common', async (importOriginal) => {
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ const actual = await importOriginal<any>();
10
+ return {
11
+ ...actual,
12
+ getSessionId: () => 'test-session',
13
+ logAuditEntry: vi.fn(),
14
+ initAuditLog: vi.fn(),
15
+ };
16
+ });
5
17
 
6
18
  describe('registerToolSafe', () => {
7
19
  const mockServer = {
@@ -10,8 +22,25 @@ describe('registerToolSafe', () => {
10
22
 
11
23
  const mockHandler = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'success' }] });
12
24
 
25
+ beforeEach(() => {
26
+ mockServer.registerTool.mockClear();
27
+ mockHandler.mockClear();
28
+ // Reset globals to safe defaults
29
+ setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
30
+ setStaticAllowedProjectUuids([]);
31
+ setDryRunMode(false);
32
+ process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
33
+ delete process.env.LIGHTDASH_TOOLS_ALLOWED_PROJECTS;
34
+ delete process.env.LIGHTDASH_DRY_RUN;
35
+ });
36
+
37
+ afterEach(() => {
38
+ delete process.env.LIGHTDASH_TOOL_SAFETY_MODE;
39
+ });
40
+
13
41
  it('should allow read-only tool in read-only mode', async () => {
14
42
  process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
43
+ setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE); // static = allow all
15
44
 
16
45
  registerToolSafe(
17
46
  mockServer,
@@ -36,6 +65,7 @@ describe('registerToolSafe', () => {
36
65
 
37
66
  it('should block destructive tool in read-only mode', async () => {
38
67
  process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
68
+ setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
39
69
 
40
70
  registerToolSafe(
41
71
  mockServer,
@@ -48,7 +78,7 @@ describe('registerToolSafe', () => {
48
78
  mockHandler,
49
79
  );
50
80
 
51
- const [, options, handler] = mockServer.registerTool.mock.calls[1];
81
+ const [, options, handler] = mockServer.registerTool.mock.calls[0];
52
82
 
53
83
  expect(options.description).toContain('[DISABLED in read-only mode]');
54
84
 
@@ -59,6 +89,7 @@ describe('registerToolSafe', () => {
59
89
 
60
90
  it('should allow destructive tool in write-destructive mode', async () => {
61
91
  process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
92
+ setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
62
93
 
63
94
  registerToolSafe(
64
95
  mockServer,
@@ -71,7 +102,7 @@ describe('registerToolSafe', () => {
71
102
  mockHandler,
72
103
  );
73
104
 
74
- const [, options, handler] = mockServer.registerTool.mock.calls[2];
105
+ const [, options, handler] = mockServer.registerTool.mock.calls[0];
75
106
 
76
107
  expect(options.description).toBe('Delete something 2');
77
108
 
@@ -81,9 +112,7 @@ describe('registerToolSafe', () => {
81
112
 
82
113
  describe('static filtering (safety-mode)', () => {
83
114
  it('should skip registration if tool is more permissive than binded mode', () => {
84
- // Set binded mode to READ_ONLY
85
115
  setStaticSafetyMode(SafetyMode.READ_ONLY);
86
-
87
116
  mockServer.registerTool.mockClear();
88
117
 
89
118
  registerToolSafe(
@@ -102,7 +131,6 @@ describe('registerToolSafe', () => {
102
131
 
103
132
  it('should allow registration if tool matches binded mode', () => {
104
133
  setStaticSafetyMode(SafetyMode.READ_ONLY);
105
-
106
134
  mockServer.registerTool.mockClear();
107
135
 
108
136
  registerToolSafe(
@@ -119,12 +147,8 @@ describe('registerToolSafe', () => {
119
147
  expect(mockServer.registerTool).toHaveBeenCalled();
120
148
  });
121
149
 
122
- it('should allow everything if binded mode is undefined', () => {
123
- // This is a bit tricky since it's a global. We might need a way to reset it.
124
- // For now, let's assume we can just pass a permissive mode or it was undefined initially.
125
- // Since we don't have a reset, let's just test that it works when set to DESTRUCTIVE.
150
+ it('should allow everything if binded mode is write-destructive', () => {
126
151
  setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
127
-
128
152
  mockServer.registerTool.mockClear();
129
153
 
130
154
  registerToolSafe(
@@ -141,4 +165,181 @@ describe('registerToolSafe', () => {
141
165
  expect(mockServer.registerTool).toHaveBeenCalled();
142
166
  });
143
167
  });
168
+
169
+ describe('project UUID allowlist', () => {
170
+ it('should allow calls when allowlist is empty (all projects permitted)', async () => {
171
+ setStaticAllowedProjectUuids([]);
172
+
173
+ registerToolSafe(
174
+ mockServer,
175
+ 'list_charts',
176
+ { description: 'List charts', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
177
+ mockHandler,
178
+ );
179
+
180
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
181
+ const result = await handler({ projectUuid: 'any-uuid' });
182
+ expect(result.isError).toBeUndefined();
183
+ expect(result.content[0].text).toBe('success');
184
+ });
185
+
186
+ it('should allow calls for a singular projectUuid in the allowlist', async () => {
187
+ setStaticAllowedProjectUuids(['uuid-allowed', 'uuid-other']);
188
+
189
+ registerToolSafe(
190
+ mockServer,
191
+ 'list_charts_allowed',
192
+ { description: 'List charts', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
193
+ mockHandler,
194
+ );
195
+
196
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
197
+ const result = await handler({ projectUuid: 'uuid-allowed' });
198
+ expect(result.isError).toBeUndefined();
199
+ expect(result.content[0].text).toBe('success');
200
+ });
201
+
202
+ it('should block calls for a singular projectUuid not in the allowlist', async () => {
203
+ setStaticAllowedProjectUuids(['uuid-allowed']);
204
+
205
+ registerToolSafe(
206
+ mockServer,
207
+ 'list_charts_blocked',
208
+ { description: 'List charts', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
209
+ mockHandler,
210
+ );
211
+
212
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
213
+ const result = await handler({ projectUuid: 'uuid-denied' });
214
+ expect(result.isError).toBe(true);
215
+ expect(result.content[0].text).toContain('not in the list of allowed projects');
216
+ expect(result.content[0].text).toContain('uuid-denied');
217
+ });
218
+
219
+ it('should allow calls with no projectUuid arg even when allowlist is set', async () => {
220
+ setStaticAllowedProjectUuids(['uuid-allowed']);
221
+
222
+ registerToolSafe(
223
+ mockServer,
224
+ 'list_projects_no_uuid',
225
+ { description: 'List projects', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
226
+ mockHandler,
227
+ );
228
+
229
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
230
+ // No projectUuid in args → allowlist does not apply
231
+ const result = await handler({});
232
+ expect(result.isError).toBeUndefined();
233
+ expect(result.content[0].text).toBe('success');
234
+ });
235
+
236
+ it('should allow when all projectUuids[] are in the allowlist', async () => {
237
+ setStaticAllowedProjectUuids(['uuid-a', 'uuid-b']);
238
+
239
+ registerToolSafe(
240
+ mockServer,
241
+ 'search_content_allowed',
242
+ { description: 'Search content', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
243
+ mockHandler,
244
+ );
245
+
246
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
247
+ const result = await handler({ projectUuids: ['uuid-a', 'uuid-b'] });
248
+ expect(result.isError).toBeUndefined();
249
+ expect(result.content[0].text).toBe('success');
250
+ });
251
+
252
+ it('should block when any UUID in projectUuids[] is not in the allowlist', async () => {
253
+ setStaticAllowedProjectUuids(['uuid-a']);
254
+
255
+ registerToolSafe(
256
+ mockServer,
257
+ 'search_content_blocked',
258
+ { description: 'Search content', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
259
+ mockHandler,
260
+ );
261
+
262
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
263
+ const result = await handler({ projectUuids: ['uuid-a', 'uuid-denied'] });
264
+ expect(result.isError).toBe(true);
265
+ expect(result.content[0].text).toContain('uuid-denied');
266
+ expect(result.content[0].text).toContain('not in the list of allowed projects');
267
+ });
268
+
269
+ it('should block when all projectUuids[] are outside the allowlist', async () => {
270
+ setStaticAllowedProjectUuids(['uuid-allowed']);
271
+
272
+ registerToolSafe(
273
+ mockServer,
274
+ 'search_content_all_blocked',
275
+ { description: 'Search content', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
276
+ mockHandler,
277
+ );
278
+
279
+ const [, , handler] = mockServer.registerTool.mock.calls[0];
280
+ const result = await handler({ projectUuids: ['uuid-x', 'uuid-y'] });
281
+ expect(result.isError).toBe(true);
282
+ expect(result.content[0].text).toContain('not in the list of allowed projects');
283
+ });
284
+ });
285
+
286
+ describe('dry-run mode', () => {
287
+ it('should not affect read-only tools in dry-run mode', async () => {
288
+ setDryRunMode(true);
289
+
290
+ registerToolSafe(
291
+ mockServer,
292
+ 'list_things_dry',
293
+ { description: 'List things', inputSchema: {}, annotations: READ_ONLY_DEFAULT },
294
+ mockHandler,
295
+ );
296
+
297
+ const [, options, handler] = mockServer.registerTool.mock.calls[0];
298
+ expect(options.description).not.toContain('[DRY-RUN]');
299
+
300
+ const result = await handler({});
301
+ expect(result.content[0].text).toBe('success');
302
+ });
303
+
304
+ it('should simulate write-idempotent tools in dry-run mode', async () => {
305
+ setDryRunMode(true);
306
+ process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
307
+
308
+ registerToolSafe(
309
+ mockServer,
310
+ 'upsert_thing_dry',
311
+ { description: 'Upsert thing', inputSchema: {}, annotations: WRITE_IDEMPOTENT },
312
+ mockHandler,
313
+ );
314
+
315
+ const [, options, handler] = mockServer.registerTool.mock.calls[0];
316
+ expect(options.description).toContain('[DRY-RUN]');
317
+
318
+ const result = await handler({ projectUuid: 'uuid-x', slug: 'my-chart' });
319
+ expect(result.isError).toBeUndefined();
320
+ expect(result.content[0].text).toContain('[DRY-RUN]');
321
+ expect(result.content[0].text).toContain('No changes were made');
322
+ // Verify the underlying handler was NOT called
323
+ expect(mockHandler).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('should simulate destructive tools in dry-run mode', async () => {
327
+ setDryRunMode(true);
328
+ process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
329
+
330
+ registerToolSafe(
331
+ mockServer,
332
+ 'delete_thing_dry',
333
+ { description: 'Delete thing', inputSchema: {}, annotations: WRITE_DESTRUCTIVE },
334
+ mockHandler,
335
+ );
336
+
337
+ const [, options, handler] = mockServer.registerTool.mock.calls[0];
338
+ expect(options.description).toContain('[DRY-RUN]');
339
+
340
+ const result = await handler({ projectUuid: 'uuid-x' });
341
+ expect(result.content[0].text).toContain('No changes were made');
342
+ expect(mockHandler).not.toHaveBeenCalled();
343
+ });
344
+ });
144
345
  });
@@ -1,13 +1,32 @@
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
 
5
12
  import type { LightdashClient } from '@lightdash-tools/client';
6
- import { isAllowed, READ_ONLY_DEFAULT } from '@lightdash-tools/common';
13
+ import {
14
+ isAllowed,
15
+ areAllProjectsAllowed,
16
+ extractProjectUuids,
17
+ READ_ONLY_DEFAULT,
18
+ logAuditEntry,
19
+ getSessionId,
20
+ } from '@lightdash-tools/common';
7
21
  import type { ToolAnnotations } from '@lightdash-tools/common';
8
22
  import type { z } from 'zod';
9
23
  import { toMcpErrorMessage } from '../errors.js';
10
- import { getStaticSafetyMode, getSafetyMode } from '../config.js';
24
+ import {
25
+ getStaticSafetyMode,
26
+ getSafetyMode,
27
+ getAllowedProjectUuids,
28
+ isDryRunMode,
29
+ } from '../config.js';
11
30
 
12
31
  /** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
13
32
  export const TOOL_PREFIX = 'lightdash_tools__';
@@ -41,7 +60,27 @@ function mergeAnnotations(overrides?: ToolAnnotations): ToolAnnotations {
41
60
  return { ...DEFAULT_ANNOTATIONS, ...overrides };
42
61
  }
43
62
 
44
- /** Registers a tool with prefix and annotations. shortName is TOOL_PREFIX + shortName. Pass annotations explicitly (e.g. READ_ONLY_DEFAULT, WRITE_IDEMPOTENT, or WRITE_DESTRUCTIVE). */
63
+ /**
64
+ * Internal marker attached to responses produced by a guardrail (safety-mode block,
65
+ * dry-run simulation, or project-allowlist denial). The audit wrapper reads this flag
66
+ * to set status = 'blocked', then strips it before returning to the MCP client.
67
+ */
68
+ type BlockedContent = TextContent & { readonly _lightdashBlocked: true };
69
+
70
+ function isGuardrailBlocked(result: TextContent): result is BlockedContent {
71
+ return (
72
+ '_lightdashBlocked' in result &&
73
+ (result as Record<string, unknown>)['_lightdashBlocked'] === true
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Registers a tool with prefix and annotations, applying all guardrail layers.
79
+ * shortName is prefixed to become TOOL_PREFIX + shortName.
80
+ * Pass annotations explicitly (e.g. READ_ONLY_DEFAULT, WRITE_IDEMPOTENT, or WRITE_DESTRUCTIVE).
81
+ *
82
+ * CLI flag --allowed-projects always takes priority over LIGHTDASH_TOOLS_ALLOWED_PROJECTS.
83
+ */
45
84
  export function registerToolSafe(
46
85
  server: unknown,
47
86
  shortName: string,
@@ -51,23 +90,26 @@ export function registerToolSafe(
51
90
  const name = TOOL_PREFIX + shortName;
52
91
  const annotations = mergeAnnotations(options.annotations);
53
92
 
54
- // Static Filtering: Skip registration if not allowed in static safety mode
93
+ // ── Static Filtering ──────────────────────────────────────────────────────
94
+ // Skip registration entirely if the tool exceeds the static safety mode.
55
95
  const staticMode = getStaticSafetyMode();
56
96
  if (staticMode && !isAllowed(staticMode, annotations)) {
57
97
  return;
58
98
  }
59
99
 
60
- // Dynamic Enforcement: Wrap handler if not allowed in current safety mode (env)
100
+ // ── Safety-mode wrapper ───────────────────────────────────────────────────
101
+ // Tool is registered but calls are rejected at runtime when the dynamic mode
102
+ // does not permit the operation.
61
103
  const mode = getSafetyMode();
62
104
  const isToolAllowed = isAllowed(mode, annotations);
105
+ const isReadOnly = !!annotations.readOnlyHint;
63
106
 
64
- // If not allowed, wrap handler to return an error and update description
65
- let finalHandler = handler;
107
+ let finalHandler: ToolHandler = handler;
66
108
  let finalDescription = options.description;
67
109
 
68
110
  if (!isToolAllowed) {
69
111
  finalDescription = `[DISABLED in ${mode} mode] ${options.description}`;
70
- finalHandler = async () => ({
112
+ finalHandler = async (): Promise<BlockedContent> => ({
71
113
  content: [
72
114
  {
73
115
  type: 'text',
@@ -75,9 +117,94 @@ export function registerToolSafe(
75
117
  },
76
118
  ],
77
119
  isError: true,
120
+ _lightdashBlocked: true,
121
+ });
122
+ } else if (isDryRunMode() && !isReadOnly) {
123
+ // ── Dry-run wrapper ─────────────────────────────────────────────────────
124
+ // Write operations are simulated; no API calls are made.
125
+ finalDescription = `[DRY-RUN] ${options.description}`;
126
+ finalHandler = async (args): Promise<BlockedContent> => ({
127
+ content: [
128
+ {
129
+ type: 'text',
130
+ text: `[DRY-RUN] Tool '${name}' would be called with: ${JSON.stringify(args, null, 2)}. No changes were made.`,
131
+ },
132
+ ],
133
+ _lightdashBlocked: true,
78
134
  });
79
135
  }
80
136
 
137
+ // ── Project allowlist wrapper ─────────────────────────────────────────────
138
+ // Reject calls targeting project UUIDs not in the configured allowlist.
139
+ // Covers both singular (projectUuid) and plural (projectUuids[]) arg shapes.
140
+ // CLI --allowed-projects takes priority over LIGHTDASH_TOOLS_ALLOWED_PROJECTS.
141
+ const allowedProjects = getAllowedProjectUuids();
142
+ if (allowedProjects.length > 0) {
143
+ const innerHandler = finalHandler;
144
+ finalHandler = async (args, extra): Promise<TextContent> => {
145
+ const projectUuids = extractProjectUuids(args);
146
+ const deniedUuids = projectUuids.filter(
147
+ (uuid) => !areAllProjectsAllowed(allowedProjects, [uuid]),
148
+ );
149
+ if (deniedUuids.length > 0) {
150
+ return {
151
+ content: [
152
+ {
153
+ type: 'text',
154
+ text: `Error: Project(s) [${deniedUuids.join(', ')}] are not in the list of allowed projects. Allowed: [${allowedProjects.join(', ')}].`,
155
+ },
156
+ ],
157
+ isError: true,
158
+ _lightdashBlocked: true,
159
+ } as BlockedContent;
160
+ }
161
+ return innerHandler(args, extra);
162
+ };
163
+ }
164
+
165
+ // ── Audit log wrapper ─────────────────────────────────────────────────────
166
+ // Outermost layer: records timing and outcome for every call.
167
+ const auditedInner = finalHandler;
168
+ finalHandler = async (args, extra): Promise<TextContent> => {
169
+ const start = Date.now();
170
+ const projectUuids = extractProjectUuids(args);
171
+ let status: 'success' | 'error' | 'blocked' = 'success';
172
+ let result: TextContent;
173
+
174
+ try {
175
+ result = await auditedInner(args, extra);
176
+ if (isGuardrailBlocked(result)) {
177
+ status = 'blocked';
178
+ } else if (result.isError) {
179
+ status = 'error';
180
+ }
181
+ } catch (err) {
182
+ status = 'error';
183
+ logAuditEntry({
184
+ timestamp: new Date().toISOString(),
185
+ sessionId: getSessionId(),
186
+ tool: name,
187
+ projectUuids: projectUuids.length > 0 ? projectUuids : undefined,
188
+ status,
189
+ durationMs: Date.now() - start,
190
+ });
191
+ throw err;
192
+ }
193
+
194
+ logAuditEntry({
195
+ timestamp: new Date().toISOString(),
196
+ sessionId: getSessionId(),
197
+ tool: name,
198
+ projectUuids: projectUuids.length > 0 ? projectUuids : undefined,
199
+ status,
200
+ durationMs: Date.now() - start,
201
+ });
202
+
203
+ // Strip the internal marker before returning to the MCP client.
204
+ const { content, isError } = result;
205
+ return { content, isError };
206
+ };
207
+
81
208
  const mergedOptions: ToolOptions = {
82
209
  ...options,
83
210
  description: finalDescription,