@lightdash-tools/mcp 0.3.2 → 0.5.0
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 +14 -6
- package/dist/bin.js +3 -3
- package/dist/config.d.ts +3 -3
- package/dist/config.js +5 -5
- package/dist/index.js +1 -1
- package/dist/tools/shared.d.ts +5 -4
- package/dist/tools/shared.js +48 -5
- package/dist/tools/shared.test.js +20 -8
- package/package.json +4 -4
- package/src/bin.ts +3 -3
- package/src/config.ts +5 -5
- package/src/index.ts +1 -1
- package/src/tools/shared.test.ts +28 -8
- package/src/tools/shared.ts +48 -5
package/README.md
CHANGED
|
@@ -30,7 +30,11 @@ npm install -g @lightdash-tools/mcp
|
|
|
30
30
|
|
|
31
31
|
### Optional (both modes)
|
|
32
32
|
|
|
33
|
-
- `
|
|
33
|
+
- `LIGHTDASH_TOOLS_SAFETY_MODE` — Safety mode for dynamic enforcement (`read-only`, `write-idempotent`, `write-destructive`). See [Safety Modes](#safety-modes) for details.
|
|
34
|
+
- `LIGHTDASH_TOOLS_ALLOWED_PROJECTS` — Comma-separated project UUIDs to restrict operations (empty = all allowed). CLI `--projects` overrides.
|
|
35
|
+
- `LIGHTDASH_TOOLS_DRY_RUN` — Set to `1`, `true`, or `yes` to simulate write operations without executing.
|
|
36
|
+
|
|
37
|
+
Prefer env vars from the parent process. Avoid plaintext `.env` when AI agents have file access. If using `.env`, use [dotenvx](https://dotenvx.com/) for encrypted secrets. See [docs/secrets-and-credentials.md](../../docs/secrets-and-credentials.md).
|
|
34
38
|
|
|
35
39
|
### Streamable HTTP only
|
|
36
40
|
|
|
@@ -74,7 +78,7 @@ With auth disabled (default), any client can call the endpoint. With `MCP_AUTH_E
|
|
|
74
78
|
|
|
75
79
|
## Tools
|
|
76
80
|
|
|
77
|
-
The server registers the following tools (names prefixed with `
|
|
81
|
+
The server registers the following tools (names prefixed with `ldt__`):
|
|
78
82
|
|
|
79
83
|
- **Projects**: `list_projects`, `get_project`, `validate_project`, `get_validation_results`
|
|
80
84
|
- **Explores**: `list_explores`, `get_explore`
|
|
@@ -106,12 +110,12 @@ lightdash-mcp --help
|
|
|
106
110
|
|
|
107
111
|
- `--http` — Run as HTTP server instead of Stdio.
|
|
108
112
|
- `--safety-mode <mode>` — Filter registered tools by safety mode (`read-only`, `write-idempotent`, `write-destructive`). Tools not allowed in this mode will not be registered, hiding them from AI agents (Static Filtering).
|
|
109
|
-
- `--projects <uuids>` — Comma-separated list of allowed project UUIDs (overrides `
|
|
110
|
-
- `--dry-run` — Simulate write operations without executing them (overrides `
|
|
113
|
+
- `--projects <uuids>` — Comma-separated list of allowed project UUIDs (overrides `LIGHTDASH_TOOLS_ALLOWED_PROJECTS`; empty = all allowed).
|
|
114
|
+
- `--dry-run` — Simulate write operations without executing them (overrides `LIGHTDASH_TOOLS_DRY_RUN`).
|
|
111
115
|
|
|
112
116
|
## Safety Modes
|
|
113
117
|
|
|
114
|
-
The MCP server implements a hierarchical safety model. You can control which tools are available to AI agents using the `
|
|
118
|
+
The MCP server implements a hierarchical safety model. You can control which tools are available to AI agents using the `LIGHTDASH_TOOLS_SAFETY_MODE` environment variable or the `--safety-mode` CLI option.
|
|
115
119
|
|
|
116
120
|
- `read-only` (default): Only allows non-modifying tools (e.g., `list_*`, `get_*`).
|
|
117
121
|
- `write-idempotent`: Allows read tools and non-destructive writes (e.g., `upsert_chart_as_code`).
|
|
@@ -119,7 +123,7 @@ The MCP server implements a hierarchical safety model. You can control which too
|
|
|
119
123
|
|
|
120
124
|
### Enforcement Layers
|
|
121
125
|
|
|
122
|
-
1. **Dynamic Enforcement (Visible but Disabled)**: Using `
|
|
126
|
+
1. **Dynamic Enforcement (Visible but Disabled)**: Using `LIGHTDASH_TOOLS_SAFETY_MODE` environment variable. Tools are registered and visible to the agent, but return an error if called. This allows agents to understand that a capability exists but is restricted.
|
|
123
127
|
2. **Static Filtering (Hidden)**: Using the `--safety-mode` CLI option. Tools not allowed in the selected mode are not registered at all. They are completely hidden from the AI agent.
|
|
124
128
|
|
|
125
129
|
When a tool is disabled via dynamic enforcement, the server will return a descriptive error message if an agent attempts to call it.
|
|
@@ -128,6 +132,10 @@ When a tool is disabled via dynamic enforcement, the server will return a descri
|
|
|
128
132
|
|
|
129
133
|
Tools with `destructiveHint: true` (e.g. `delete_member`) perform irreversible or high-impact actions. MCP clients should show a warning and/or require user confirmation before executing them. AI agents should ask the user for explicit confirmation before calling such tools.
|
|
130
134
|
|
|
135
|
+
### Input validation
|
|
136
|
+
|
|
137
|
+
Resource IDs (project UUIDs, slugs) are validated before execution. Invalid inputs (control characters, `?`, `#`, `%`, path traversal) are rejected. This guards against adversarial or hallucinated inputs when used by AI agents. See [docs/agent-context/CONTEXT.md](../../docs/agent-context/CONTEXT.md) for agent-specific guidance.
|
|
138
|
+
|
|
131
139
|
## Testing
|
|
132
140
|
|
|
133
141
|
This package includes unit tests and integration tests. Integration tests run against a real Lightdash API and are only executed if the required environment variables are set.
|
package/dist/bin.js
CHANGED
|
@@ -44,11 +44,11 @@ const program = new commander_1.Command();
|
|
|
44
44
|
program
|
|
45
45
|
.name('lightdash-mcp')
|
|
46
46
|
.description('MCP server for Lightdash AI')
|
|
47
|
-
.version('0.
|
|
47
|
+
.version('0.5.0')
|
|
48
48
|
.option('--http', 'Run as HTTP server instead of Stdio')
|
|
49
49
|
.option('--safety-mode <mode>', 'Filter registered tools by safety mode (read-only, write-idempotent, write-destructive)')
|
|
50
|
-
.option('--projects <uuids>', 'Comma-separated list of allowed project UUIDs (overrides
|
|
51
|
-
.option('--dry-run', 'Simulate write operations without executing them (overrides
|
|
50
|
+
.option('--projects <uuids>', 'Comma-separated list of allowed project UUIDs (overrides LIGHTDASH_TOOLS_ALLOWED_PROJECTS; empty = all allowed)')
|
|
51
|
+
.option('--dry-run', 'Simulate write operations without executing them (overrides LIGHTDASH_TOOLS_DRY_RUN)')
|
|
52
52
|
.action((options) => {
|
|
53
53
|
if (options.safetyMode) {
|
|
54
54
|
if (Object.values(common_1.SafetyMode).includes(options.safetyMode)) {
|
package/dist/config.d.ts
CHANGED
|
@@ -24,12 +24,12 @@ export declare function setStaticSafetyMode(mode: SafetyMode): void;
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function getAllowedProjectUuids(): string[];
|
|
26
26
|
/**
|
|
27
|
-
* Sets the project UUID allowlist from the CLI (overrides
|
|
27
|
+
* Sets the project UUID allowlist from the CLI (overrides LIGHTDASH_TOOLS_ALLOWED_PROJECTS).
|
|
28
28
|
*/
|
|
29
29
|
export declare function setStaticAllowedProjectUuids(uuids: string[]): void;
|
|
30
30
|
/**
|
|
31
31
|
* Returns true when dry-run mode is active.
|
|
32
|
-
* CLI flag overrides the
|
|
32
|
+
* CLI flag overrides the LIGHTDASH_TOOLS_DRY_RUN environment variable.
|
|
33
33
|
*/
|
|
34
34
|
export declare function isDryRunMode(): boolean;
|
|
35
35
|
/**
|
|
@@ -37,7 +37,7 @@ export declare function isDryRunMode(): boolean;
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function setDryRunMode(enabled: boolean): void;
|
|
39
39
|
/**
|
|
40
|
-
* Returns the audit log file path from
|
|
40
|
+
* Returns the audit log file path from LIGHTDASH_TOOLS_AUDIT_LOG, or undefined to use stderr.
|
|
41
41
|
*/
|
|
42
42
|
export declare function getAuditLogPath(): string | undefined;
|
|
43
43
|
/**
|
package/dist/config.js
CHANGED
|
@@ -45,19 +45,19 @@ function getAllowedProjectUuids() {
|
|
|
45
45
|
return globalStaticAllowedProjectUuids !== null && globalStaticAllowedProjectUuids !== void 0 ? globalStaticAllowedProjectUuids : (0, common_1.getAllowedProjectUuidsFromEnv)();
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
* Sets the project UUID allowlist from the CLI (overrides
|
|
48
|
+
* Sets the project UUID allowlist from the CLI (overrides LIGHTDASH_TOOLS_ALLOWED_PROJECTS).
|
|
49
49
|
*/
|
|
50
50
|
function setStaticAllowedProjectUuids(uuids) {
|
|
51
51
|
globalStaticAllowedProjectUuids = uuids;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
54
|
* Returns true when dry-run mode is active.
|
|
55
|
-
* CLI flag overrides the
|
|
55
|
+
* CLI flag overrides the LIGHTDASH_TOOLS_DRY_RUN environment variable.
|
|
56
56
|
*/
|
|
57
57
|
function isDryRunMode() {
|
|
58
58
|
if (globalDryRunMode !== undefined)
|
|
59
59
|
return globalDryRunMode;
|
|
60
|
-
const v = process.env.
|
|
60
|
+
const v = process.env.LIGHTDASH_TOOLS_DRY_RUN;
|
|
61
61
|
return v === '1' || v === 'true' || v === 'yes';
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
@@ -67,10 +67,10 @@ function setDryRunMode(enabled) {
|
|
|
67
67
|
globalDryRunMode = enabled;
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* Returns the audit log file path from
|
|
70
|
+
* Returns the audit log file path from LIGHTDASH_TOOLS_AUDIT_LOG, or undefined to use stderr.
|
|
71
71
|
*/
|
|
72
72
|
function getAuditLogPath() {
|
|
73
|
-
return process.env.
|
|
73
|
+
return process.env.LIGHTDASH_TOOLS_AUDIT_LOG || undefined;
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
76
|
* Builds a LightdashClient from environment variables (and optional overrides).
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ function main() {
|
|
|
24
24
|
const client = (0, config_js_1.getClient)();
|
|
25
25
|
const server = new mcp_js_1.McpServer({
|
|
26
26
|
name: 'lightdash-mcp',
|
|
27
|
-
version: '
|
|
27
|
+
version: '0.4.0',
|
|
28
28
|
});
|
|
29
29
|
(0, index_js_1.registerTools)(server, client);
|
|
30
30
|
const transport = new stdio_js_1.StdioServerTransport();
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
* Guardrail layers applied by registerToolSafe (outer → inner):
|
|
5
5
|
* 1. Audit log wrapper — captures timing and outcome for every call.
|
|
6
6
|
* 2. Project allowlist — rejects calls targeting disallowed project UUIDs at runtime.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
7
|
+
* 3. Input validation — rejects invalid resource IDs (control chars, ?, #, %, path traversal).
|
|
8
|
+
* 4. Dry-run wrapper — simulates writes without executing them (registration-time).
|
|
9
|
+
* 5. Safety-mode wrapper — disables tools that exceed the configured safety level.
|
|
10
|
+
* 6. Raw handler — the actual tool implementation.
|
|
10
11
|
*/
|
|
11
12
|
import type { LightdashClient } from '@lightdash-tools/client';
|
|
12
13
|
import type { ToolAnnotations } from '@lightdash-tools/common';
|
|
13
14
|
import type { z } from 'zod';
|
|
14
15
|
/** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
|
|
15
|
-
export declare const TOOL_PREFIX = "
|
|
16
|
+
export declare const TOOL_PREFIX = "ldt__";
|
|
16
17
|
export type TextContent = {
|
|
17
18
|
content: Array<{
|
|
18
19
|
type: 'text';
|
package/dist/tools/shared.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Guardrail layers applied by registerToolSafe (outer → inner):
|
|
6
6
|
* 1. Audit log wrapper — captures timing and outcome for every call.
|
|
7
7
|
* 2. Project allowlist — rejects calls targeting disallowed project UUIDs at runtime.
|
|
8
|
-
* 3.
|
|
9
|
-
* 4.
|
|
10
|
-
* 5.
|
|
8
|
+
* 3. Input validation — rejects invalid resource IDs (control chars, ?, #, %, path traversal).
|
|
9
|
+
* 4. Dry-run wrapper — simulates writes without executing them (registration-time).
|
|
10
|
+
* 5. Safety-mode wrapper — disables tools that exceed the configured safety level.
|
|
11
|
+
* 6. Raw handler — the actual tool implementation.
|
|
11
12
|
*/
|
|
12
13
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
13
14
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
@@ -26,7 +27,7 @@ const common_1 = require("@lightdash-tools/common");
|
|
|
26
27
|
const errors_js_1 = require("../errors.js");
|
|
27
28
|
const config_js_1 = require("../config.js");
|
|
28
29
|
/** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
|
|
29
|
-
exports.TOOL_PREFIX = '
|
|
30
|
+
exports.TOOL_PREFIX = 'ldt__';
|
|
30
31
|
// Re-export presets for convenience and backward compatibility in tools
|
|
31
32
|
var common_2 = require("@lightdash-tools/common");
|
|
32
33
|
Object.defineProperty(exports, "READ_ONLY_DEFAULT", { enumerable: true, get: function () { return common_2.READ_ONLY_DEFAULT; } });
|
|
@@ -74,7 +75,7 @@ function registerToolSafe(server, shortName, options, handler) {
|
|
|
74
75
|
content: [
|
|
75
76
|
{
|
|
76
77
|
type: 'text',
|
|
77
|
-
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change
|
|
78
|
+
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change LIGHTDASH_TOOLS_SAFETY_MODE.`,
|
|
78
79
|
},
|
|
79
80
|
],
|
|
80
81
|
isError: true,
|
|
@@ -98,6 +99,48 @@ function registerToolSafe(server, shortName, options, handler) {
|
|
|
98
99
|
});
|
|
99
100
|
});
|
|
100
101
|
}
|
|
102
|
+
// ── Input validation wrapper ─────────────────────────────────────────────
|
|
103
|
+
// Validate resource IDs (projectUuid, slug, etc.) before handler.
|
|
104
|
+
const validatedInner = finalHandler;
|
|
105
|
+
finalHandler = (args, extra) => __awaiter(this, void 0, void 0, function* () {
|
|
106
|
+
const projectUuids = (0, common_1.extractProjectUuids)(args);
|
|
107
|
+
for (const uuid of projectUuids) {
|
|
108
|
+
try {
|
|
109
|
+
(0, common_1.validateResourceId)(uuid);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
type: 'text',
|
|
116
|
+
text: `Error: Invalid resource ID: ${err instanceof Error ? err.message : String(err)}`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
isError: true,
|
|
120
|
+
_lightdashBlocked: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const a = args;
|
|
125
|
+
if (typeof (a === null || a === void 0 ? void 0 : a.slug) === 'string') {
|
|
126
|
+
try {
|
|
127
|
+
(0, common_1.validateResourceId)(a.slug);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: 'text',
|
|
134
|
+
text: `Error: Invalid slug: ${err instanceof Error ? err.message : String(err)}`,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
isError: true,
|
|
138
|
+
_lightdashBlocked: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return validatedInner(args, extra);
|
|
143
|
+
});
|
|
101
144
|
// ── Project allowlist wrapper ─────────────────────────────────────────────
|
|
102
145
|
// Reject calls targeting project UUIDs not in the configured allowlist.
|
|
103
146
|
// Covers both singular (projectUuid) and plural (projectUuids[]) arg shapes.
|
|
@@ -31,15 +31,15 @@ vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0
|
|
|
31
31
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
32
32
|
(0, config_js_1.setStaticAllowedProjectUuids)([]);
|
|
33
33
|
(0, config_js_1.setDryRunMode)(false);
|
|
34
|
-
process.env.
|
|
34
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
35
35
|
delete process.env.LIGHTDASH_TOOLS_ALLOWED_PROJECTS;
|
|
36
|
-
delete process.env.
|
|
36
|
+
delete process.env.LIGHTDASH_TOOLS_DRY_RUN;
|
|
37
37
|
});
|
|
38
38
|
(0, vitest_1.afterEach)(() => {
|
|
39
|
-
delete process.env.
|
|
39
|
+
delete process.env.LIGHTDASH_TOOLS_SAFETY_MODE;
|
|
40
40
|
});
|
|
41
41
|
(0, vitest_1.it)('should allow read-only tool in read-only mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
42
|
-
process.env.
|
|
42
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
|
|
43
43
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE); // static = allow all
|
|
44
44
|
(0, shared_1.registerToolSafe)(mockServer, 'test_tool', {
|
|
45
45
|
description: 'Test description',
|
|
@@ -54,7 +54,7 @@ vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0
|
|
|
54
54
|
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
55
55
|
}));
|
|
56
56
|
(0, vitest_1.it)('should block destructive tool in read-only mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
57
|
-
process.env.
|
|
57
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
|
|
58
58
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
59
59
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_tool', {
|
|
60
60
|
description: 'Delete something',
|
|
@@ -68,7 +68,7 @@ vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0
|
|
|
68
68
|
(0, vitest_1.expect)(result.content[0].text).toContain('disabled in read-only mode');
|
|
69
69
|
}));
|
|
70
70
|
(0, vitest_1.it)('should allow destructive tool in write-destructive mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
71
|
-
process.env.
|
|
71
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
72
72
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
73
73
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_tool_2', {
|
|
74
74
|
description: 'Delete something 2',
|
|
@@ -80,6 +80,18 @@ vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0
|
|
|
80
80
|
const result = yield handler({});
|
|
81
81
|
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
82
82
|
}));
|
|
83
|
+
(0, vitest_1.it)('should reject invalid projectUuid before calling handler', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
84
|
+
(0, shared_1.registerToolSafe)(mockServer, 'list_tool', {
|
|
85
|
+
description: 'List something',
|
|
86
|
+
inputSchema: {},
|
|
87
|
+
annotations: shared_1.READ_ONLY_DEFAULT,
|
|
88
|
+
}, mockHandler);
|
|
89
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
90
|
+
const result = yield handler({ projectUuid: 'uuid?fields=name' });
|
|
91
|
+
(0, vitest_1.expect)(mockHandler).not.toHaveBeenCalled();
|
|
92
|
+
(0, vitest_1.expect)(result.isError).toBe(true);
|
|
93
|
+
(0, vitest_1.expect)(result.content[0].text).toContain('Invalid resource ID');
|
|
94
|
+
}));
|
|
83
95
|
(0, vitest_1.describe)('static filtering (safety-mode)', () => {
|
|
84
96
|
(0, vitest_1.it)('should skip registration if tool is more permissive than binded mode', () => {
|
|
85
97
|
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.READ_ONLY);
|
|
@@ -184,7 +196,7 @@ vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0
|
|
|
184
196
|
}));
|
|
185
197
|
(0, vitest_1.it)('should simulate write-idempotent tools in dry-run mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
186
198
|
(0, config_js_1.setDryRunMode)(true);
|
|
187
|
-
process.env.
|
|
199
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
188
200
|
(0, shared_1.registerToolSafe)(mockServer, 'upsert_thing_dry', { description: 'Upsert thing', inputSchema: {}, annotations: shared_1.WRITE_IDEMPOTENT }, mockHandler);
|
|
189
201
|
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
190
202
|
(0, vitest_1.expect)(options.description).toContain('[DRY-RUN]');
|
|
@@ -197,7 +209,7 @@ vitest_1.vi.mock('@lightdash-tools/common', (importOriginal) => __awaiter(void 0
|
|
|
197
209
|
}));
|
|
198
210
|
(0, vitest_1.it)('should simulate destructive tools in dry-run mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
199
211
|
(0, config_js_1.setDryRunMode)(true);
|
|
200
|
-
process.env.
|
|
212
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
201
213
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_thing_dry', { description: 'Delete thing', inputSchema: {}, annotations: shared_1.WRITE_DESTRUCTIVE }, mockHandler);
|
|
202
214
|
const [, options, handler] = mockServer.registerTool.mock.calls[0];
|
|
203
215
|
(0, vitest_1.expect)(options.description).toContain('[DRY-RUN]');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash-tools/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server and utilities for Lightdash AI.",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
15
15
|
"commander": "^14.0.3",
|
|
16
16
|
"zod": "^4.3.6",
|
|
17
|
-
"@lightdash-tools/client": "0.
|
|
18
|
-
"@lightdash-tools/common": "0.
|
|
17
|
+
"@lightdash-tools/client": "0.5.0",
|
|
18
|
+
"@lightdash-tools/common": "0.5.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^25.
|
|
21
|
+
"@types/node": "^25.3.4"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc",
|
package/src/bin.ts
CHANGED
|
@@ -12,7 +12,7 @@ const program = new Command();
|
|
|
12
12
|
program
|
|
13
13
|
.name('lightdash-mcp')
|
|
14
14
|
.description('MCP server for Lightdash AI')
|
|
15
|
-
.version('0.
|
|
15
|
+
.version('0.5.0')
|
|
16
16
|
.option('--http', 'Run as HTTP server instead of Stdio')
|
|
17
17
|
.option(
|
|
18
18
|
'--safety-mode <mode>',
|
|
@@ -20,11 +20,11 @@ program
|
|
|
20
20
|
)
|
|
21
21
|
.option(
|
|
22
22
|
'--projects <uuids>',
|
|
23
|
-
'Comma-separated list of allowed project UUIDs (overrides
|
|
23
|
+
'Comma-separated list of allowed project UUIDs (overrides LIGHTDASH_TOOLS_ALLOWED_PROJECTS; empty = all allowed)',
|
|
24
24
|
)
|
|
25
25
|
.option(
|
|
26
26
|
'--dry-run',
|
|
27
|
-
'Simulate write operations without executing them (overrides
|
|
27
|
+
'Simulate write operations without executing them (overrides LIGHTDASH_TOOLS_DRY_RUN)',
|
|
28
28
|
)
|
|
29
29
|
.action((options) => {
|
|
30
30
|
if (options.safetyMode) {
|
package/src/config.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function getAllowedProjectUuids(): string[] {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Sets the project UUID allowlist from the CLI (overrides
|
|
46
|
+
* Sets the project UUID allowlist from the CLI (overrides LIGHTDASH_TOOLS_ALLOWED_PROJECTS).
|
|
47
47
|
*/
|
|
48
48
|
export function setStaticAllowedProjectUuids(uuids: string[]): void {
|
|
49
49
|
globalStaticAllowedProjectUuids = uuids;
|
|
@@ -51,11 +51,11 @@ export function setStaticAllowedProjectUuids(uuids: string[]): void {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Returns true when dry-run mode is active.
|
|
54
|
-
* CLI flag overrides the
|
|
54
|
+
* CLI flag overrides the LIGHTDASH_TOOLS_DRY_RUN environment variable.
|
|
55
55
|
*/
|
|
56
56
|
export function isDryRunMode(): boolean {
|
|
57
57
|
if (globalDryRunMode !== undefined) return globalDryRunMode;
|
|
58
|
-
const v = process.env.
|
|
58
|
+
const v = process.env.LIGHTDASH_TOOLS_DRY_RUN;
|
|
59
59
|
return v === '1' || v === 'true' || v === 'yes';
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -67,10 +67,10 @@ export function setDryRunMode(enabled: boolean): void {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Returns the audit log file path from
|
|
70
|
+
* Returns the audit log file path from LIGHTDASH_TOOLS_AUDIT_LOG, or undefined to use stderr.
|
|
71
71
|
*/
|
|
72
72
|
export function getAuditLogPath(): string | undefined {
|
|
73
|
-
return process.env.
|
|
73
|
+
return process.env.LIGHTDASH_TOOLS_AUDIT_LOG || undefined;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
package/src/index.ts
CHANGED
package/src/tools/shared.test.ts
CHANGED
|
@@ -29,17 +29,17 @@ describe('registerToolSafe', () => {
|
|
|
29
29
|
setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
|
|
30
30
|
setStaticAllowedProjectUuids([]);
|
|
31
31
|
setDryRunMode(false);
|
|
32
|
-
process.env.
|
|
32
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
|
|
33
33
|
delete process.env.LIGHTDASH_TOOLS_ALLOWED_PROJECTS;
|
|
34
|
-
delete process.env.
|
|
34
|
+
delete process.env.LIGHTDASH_TOOLS_DRY_RUN;
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
afterEach(() => {
|
|
38
|
-
delete process.env.
|
|
38
|
+
delete process.env.LIGHTDASH_TOOLS_SAFETY_MODE;
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it('should allow read-only tool in read-only mode', async () => {
|
|
42
|
-
process.env.
|
|
42
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.READ_ONLY;
|
|
43
43
|
setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE); // static = allow all
|
|
44
44
|
|
|
45
45
|
registerToolSafe(
|
|
@@ -64,7 +64,7 @@ describe('registerToolSafe', () => {
|
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it('should block destructive tool in read-only mode', async () => {
|
|
67
|
-
process.env.
|
|
67
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.READ_ONLY;
|
|
68
68
|
setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
|
|
69
69
|
|
|
70
70
|
registerToolSafe(
|
|
@@ -88,7 +88,7 @@ describe('registerToolSafe', () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
it('should allow destructive tool in write-destructive mode', async () => {
|
|
91
|
-
process.env.
|
|
91
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
|
|
92
92
|
setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
|
|
93
93
|
|
|
94
94
|
registerToolSafe(
|
|
@@ -110,6 +110,26 @@ describe('registerToolSafe', () => {
|
|
|
110
110
|
expect(result.content[0].text).toBe('success');
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it('should reject invalid projectUuid before calling handler', async () => {
|
|
114
|
+
registerToolSafe(
|
|
115
|
+
mockServer,
|
|
116
|
+
'list_tool',
|
|
117
|
+
{
|
|
118
|
+
description: 'List something',
|
|
119
|
+
inputSchema: {},
|
|
120
|
+
annotations: READ_ONLY_DEFAULT,
|
|
121
|
+
},
|
|
122
|
+
mockHandler,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const [, , handler] = mockServer.registerTool.mock.calls[0];
|
|
126
|
+
const result = await handler({ projectUuid: 'uuid?fields=name' });
|
|
127
|
+
|
|
128
|
+
expect(mockHandler).not.toHaveBeenCalled();
|
|
129
|
+
expect(result.isError).toBe(true);
|
|
130
|
+
expect(result.content[0].text).toContain('Invalid resource ID');
|
|
131
|
+
});
|
|
132
|
+
|
|
113
133
|
describe('static filtering (safety-mode)', () => {
|
|
114
134
|
it('should skip registration if tool is more permissive than binded mode', () => {
|
|
115
135
|
setStaticSafetyMode(SafetyMode.READ_ONLY);
|
|
@@ -303,7 +323,7 @@ describe('registerToolSafe', () => {
|
|
|
303
323
|
|
|
304
324
|
it('should simulate write-idempotent tools in dry-run mode', async () => {
|
|
305
325
|
setDryRunMode(true);
|
|
306
|
-
process.env.
|
|
326
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
|
|
307
327
|
|
|
308
328
|
registerToolSafe(
|
|
309
329
|
mockServer,
|
|
@@ -325,7 +345,7 @@ describe('registerToolSafe', () => {
|
|
|
325
345
|
|
|
326
346
|
it('should simulate destructive tools in dry-run mode', async () => {
|
|
327
347
|
setDryRunMode(true);
|
|
328
|
-
process.env.
|
|
348
|
+
process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
|
|
329
349
|
|
|
330
350
|
registerToolSafe(
|
|
331
351
|
mockServer,
|
package/src/tools/shared.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Guardrail layers applied by registerToolSafe (outer → inner):
|
|
5
5
|
* 1. Audit log wrapper — captures timing and outcome for every call.
|
|
6
6
|
* 2. Project allowlist — rejects calls targeting disallowed project UUIDs at runtime.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
7
|
+
* 3. Input validation — rejects invalid resource IDs (control chars, ?, #, %, path traversal).
|
|
8
|
+
* 4. Dry-run wrapper — simulates writes without executing them (registration-time).
|
|
9
|
+
* 5. Safety-mode wrapper — disables tools that exceed the configured safety level.
|
|
10
|
+
* 6. Raw handler — the actual tool implementation.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { LightdashClient } from '@lightdash-tools/client';
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
READ_ONLY_DEFAULT,
|
|
18
19
|
logAuditEntry,
|
|
19
20
|
getSessionId,
|
|
21
|
+
validateResourceId,
|
|
20
22
|
} from '@lightdash-tools/common';
|
|
21
23
|
import type { ToolAnnotations } from '@lightdash-tools/common';
|
|
22
24
|
import type { z } from 'zod';
|
|
@@ -29,7 +31,7 @@ import {
|
|
|
29
31
|
} from '../config.js';
|
|
30
32
|
|
|
31
33
|
/** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
|
|
32
|
-
export const TOOL_PREFIX = '
|
|
34
|
+
export const TOOL_PREFIX = 'ldt__';
|
|
33
35
|
|
|
34
36
|
export type TextContent = {
|
|
35
37
|
content: Array<{ type: 'text'; text: string }>;
|
|
@@ -113,7 +115,7 @@ export function registerToolSafe(
|
|
|
113
115
|
content: [
|
|
114
116
|
{
|
|
115
117
|
type: 'text',
|
|
116
|
-
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change
|
|
118
|
+
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change LIGHTDASH_TOOLS_SAFETY_MODE.`,
|
|
117
119
|
},
|
|
118
120
|
],
|
|
119
121
|
isError: true,
|
|
@@ -134,6 +136,47 @@ export function registerToolSafe(
|
|
|
134
136
|
});
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
// ── Input validation wrapper ─────────────────────────────────────────────
|
|
140
|
+
// Validate resource IDs (projectUuid, slug, etc.) before handler.
|
|
141
|
+
const validatedInner = finalHandler;
|
|
142
|
+
finalHandler = async (args, extra): Promise<TextContent> => {
|
|
143
|
+
const projectUuids = extractProjectUuids(args);
|
|
144
|
+
for (const uuid of projectUuids) {
|
|
145
|
+
try {
|
|
146
|
+
validateResourceId(uuid);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: 'text',
|
|
152
|
+
text: `Error: Invalid resource ID: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
isError: true,
|
|
156
|
+
_lightdashBlocked: true,
|
|
157
|
+
} as BlockedContent;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const a = args as Record<string, unknown>;
|
|
161
|
+
if (typeof a?.slug === 'string') {
|
|
162
|
+
try {
|
|
163
|
+
validateResourceId(a.slug);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: 'text',
|
|
169
|
+
text: `Error: Invalid slug: ${err instanceof Error ? err.message : String(err)}`,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
isError: true,
|
|
173
|
+
_lightdashBlocked: true,
|
|
174
|
+
} as BlockedContent;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return validatedInner(args, extra);
|
|
178
|
+
};
|
|
179
|
+
|
|
137
180
|
// ── Project allowlist wrapper ─────────────────────────────────────────────
|
|
138
181
|
// Reject calls targeting project UUIDs not in the configured allowlist.
|
|
139
182
|
// Covers both singular (projectUuid) and plural (projectUuids[]) arg shapes.
|