@lightdash-tools/mcp 0.2.5 → 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 +19 -4
- 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/dist/tools/spaces.js +88 -0
- 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/src/tools/spaces.ts +172 -1
package/src/tools/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/shared.test.ts
CHANGED
|
@@ -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[
|
|
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[
|
|
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
|
|
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
|
});
|
package/src/tools/shared.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
package/src/tools/spaces.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import type { LightdashClient } from '@lightdash-tools/client';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
wrapTool,
|
|
10
|
+
registerToolSafe,
|
|
11
|
+
READ_ONLY_DEFAULT,
|
|
12
|
+
WRITE_IDEMPOTENT,
|
|
13
|
+
WRITE_DESTRUCTIVE,
|
|
14
|
+
} from './shared.js';
|
|
15
|
+
import { type SpaceMemberRole } from '@lightdash-tools/common';
|
|
9
16
|
|
|
10
17
|
export function registerSpaceTools(server: McpServer, client: LightdashClient): void {
|
|
11
18
|
registerToolSafe(
|
|
@@ -43,4 +50,168 @@ export function registerSpaceTools(server: McpServer, client: LightdashClient):
|
|
|
43
50
|
},
|
|
44
51
|
),
|
|
45
52
|
);
|
|
53
|
+
|
|
54
|
+
registerToolSafe(
|
|
55
|
+
server,
|
|
56
|
+
'grant_user_space_access',
|
|
57
|
+
{
|
|
58
|
+
title: 'Grant user access to space',
|
|
59
|
+
description: 'Grant a user access to a space',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
projectUuid: z.string().describe('Project UUID'),
|
|
62
|
+
spaceUuid: z.string().describe('Space UUID'),
|
|
63
|
+
userUuid: z.string().describe('User UUID'),
|
|
64
|
+
spaceRole: z.enum(['viewer', 'editor', 'admin']).describe('Space role'),
|
|
65
|
+
},
|
|
66
|
+
annotations: WRITE_IDEMPOTENT,
|
|
67
|
+
},
|
|
68
|
+
wrapTool(
|
|
69
|
+
client,
|
|
70
|
+
(c) =>
|
|
71
|
+
async ({
|
|
72
|
+
projectUuid,
|
|
73
|
+
spaceUuid,
|
|
74
|
+
userUuid,
|
|
75
|
+
spaceRole,
|
|
76
|
+
}: {
|
|
77
|
+
projectUuid: string;
|
|
78
|
+
spaceUuid: string;
|
|
79
|
+
userUuid: string;
|
|
80
|
+
spaceRole: string;
|
|
81
|
+
}) => {
|
|
82
|
+
await c.v1.spaces.grantUserAccessToSpace(projectUuid, spaceUuid, {
|
|
83
|
+
userUuid,
|
|
84
|
+
spaceRole: spaceRole as SpaceMemberRole,
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
text: `Successfully granted ${spaceRole} access to user ${userUuid} in space ${spaceUuid}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
registerToolSafe(
|
|
99
|
+
server,
|
|
100
|
+
'revoke_user_space_access',
|
|
101
|
+
{
|
|
102
|
+
title: 'Revoke user access to space',
|
|
103
|
+
description: "Revoke a user's access to a space",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
projectUuid: z.string().describe('Project UUID'),
|
|
106
|
+
spaceUuid: z.string().describe('Space UUID'),
|
|
107
|
+
userUuid: z.string().describe('User UUID'),
|
|
108
|
+
},
|
|
109
|
+
annotations: WRITE_DESTRUCTIVE,
|
|
110
|
+
},
|
|
111
|
+
wrapTool(
|
|
112
|
+
client,
|
|
113
|
+
(c) =>
|
|
114
|
+
async ({
|
|
115
|
+
projectUuid,
|
|
116
|
+
spaceUuid,
|
|
117
|
+
userUuid,
|
|
118
|
+
}: {
|
|
119
|
+
projectUuid: string;
|
|
120
|
+
spaceUuid: string;
|
|
121
|
+
userUuid: string;
|
|
122
|
+
}) => {
|
|
123
|
+
await c.v1.spaces.revokeUserAccessToSpace(projectUuid, spaceUuid, userUuid);
|
|
124
|
+
return {
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: 'text',
|
|
128
|
+
text: `Successfully revoked access for user ${userUuid} in space ${spaceUuid}`,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
registerToolSafe(
|
|
137
|
+
server,
|
|
138
|
+
'grant_group_space_access',
|
|
139
|
+
{
|
|
140
|
+
title: 'Grant group access to space',
|
|
141
|
+
description: 'Grant a group access to a space',
|
|
142
|
+
inputSchema: {
|
|
143
|
+
projectUuid: z.string().describe('Project UUID'),
|
|
144
|
+
spaceUuid: z.string().describe('Space UUID'),
|
|
145
|
+
groupUuid: z.string().describe('Group UUID'),
|
|
146
|
+
spaceRole: z.enum(['viewer', 'editor', 'admin']).describe('Space role'),
|
|
147
|
+
},
|
|
148
|
+
annotations: WRITE_IDEMPOTENT,
|
|
149
|
+
},
|
|
150
|
+
wrapTool(
|
|
151
|
+
client,
|
|
152
|
+
(c) =>
|
|
153
|
+
async ({
|
|
154
|
+
projectUuid,
|
|
155
|
+
spaceUuid,
|
|
156
|
+
groupUuid,
|
|
157
|
+
spaceRole,
|
|
158
|
+
}: {
|
|
159
|
+
projectUuid: string;
|
|
160
|
+
spaceUuid: string;
|
|
161
|
+
groupUuid: string;
|
|
162
|
+
spaceRole: string;
|
|
163
|
+
}) => {
|
|
164
|
+
await c.v1.spaces.grantGroupAccessToSpace(projectUuid, spaceUuid, {
|
|
165
|
+
groupUuid,
|
|
166
|
+
spaceRole: spaceRole as SpaceMemberRole,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: 'text',
|
|
172
|
+
text: `Successfully granted ${spaceRole} access to group ${groupUuid} in space ${spaceUuid}`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
registerToolSafe(
|
|
181
|
+
server,
|
|
182
|
+
'revoke_group_space_access',
|
|
183
|
+
{
|
|
184
|
+
title: 'Revoke group access to space',
|
|
185
|
+
description: "Revoke a group's access to a space",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
projectUuid: z.string().describe('Project UUID'),
|
|
188
|
+
spaceUuid: z.string().describe('Space UUID'),
|
|
189
|
+
groupUuid: z.string().describe('Group UUID'),
|
|
190
|
+
},
|
|
191
|
+
annotations: WRITE_DESTRUCTIVE,
|
|
192
|
+
},
|
|
193
|
+
wrapTool(
|
|
194
|
+
client,
|
|
195
|
+
(c) =>
|
|
196
|
+
async ({
|
|
197
|
+
projectUuid,
|
|
198
|
+
spaceUuid,
|
|
199
|
+
groupUuid,
|
|
200
|
+
}: {
|
|
201
|
+
projectUuid: string;
|
|
202
|
+
spaceUuid: string;
|
|
203
|
+
groupUuid: string;
|
|
204
|
+
}) => {
|
|
205
|
+
await c.v1.spaces.revokeGroupAccessToSpace(projectUuid, spaceUuid, groupUuid);
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: 'text',
|
|
210
|
+
text: `Successfully revoked access for group ${groupUuid} in space ${spaceUuid}`,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
);
|
|
46
217
|
}
|