@lightdash-tools/mcp 0.4.0 → 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 CHANGED
@@ -30,7 +30,11 @@ npm install -g @lightdash-tools/mcp
30
30
 
31
31
  ### Optional (both modes)
32
32
 
33
- - `LIGHTDASH_TOOL_SAFETY_MODE` — Safety mode for dynamic enforcement (`read-only`, `write-idempotent`, `write-destructive`). See [Safety Modes](#safety-modes) for details.
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
 
@@ -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 `LIGHTDASH_ALLOWED_PROJECTS`; empty = all allowed).
110
- - `--dry-run` — Simulate write operations without executing them (overrides `LIGHTDASH_DRY_RUN`).
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 `LIGHTDASH_TOOL_SAFETY_MODE` environment variable or the `--safety-mode` CLI option.
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 `LIGHTDASH_TOOL_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.
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.4.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 LIGHTDASH_ALLOWED_PROJECTS; empty = all allowed)')
51
- .option('--dry-run', 'Simulate write operations without executing them (overrides LIGHTDASH_DRY_RUN)')
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 LIGHTDASH_ALLOWED_PROJECTS).
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 LIGHTDASH_DRY_RUN environment variable.
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 LIGHTDASH_AUDIT_LOG, or undefined to use stderr.
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 LIGHTDASH_ALLOWED_PROJECTS).
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 LIGHTDASH_DRY_RUN environment variable.
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.LIGHTDASH_DRY_RUN;
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 LIGHTDASH_AUDIT_LOG, or undefined to use stderr.
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.LIGHTDASH_AUDIT_LOG || undefined;
73
+ return process.env.LIGHTDASH_TOOLS_AUDIT_LOG || undefined;
74
74
  }
75
75
  /**
76
76
  * Builds a LightdashClient from environment variables (and optional overrides).
@@ -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. 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.
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';
@@ -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. 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.
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); }); }
@@ -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 LIGHTDASH_TOOL_SAFETY_MODE.`,
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.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
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.LIGHTDASH_DRY_RUN;
36
+ delete process.env.LIGHTDASH_TOOLS_DRY_RUN;
37
37
  });
38
38
  (0, vitest_1.afterEach)(() => {
39
- delete process.env.LIGHTDASH_TOOL_SAFETY_MODE;
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.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
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.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
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.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
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.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
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.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
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.4.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.4.0",
18
- "@lightdash-tools/common": "0.4.0"
17
+ "@lightdash-tools/client": "0.5.0",
18
+ "@lightdash-tools/common": "0.5.0"
19
19
  },
20
20
  "devDependencies": {
21
- "@types/node": "^25.2.3"
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.4.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 LIGHTDASH_ALLOWED_PROJECTS; empty = all allowed)',
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 LIGHTDASH_DRY_RUN)',
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 LIGHTDASH_ALLOWED_PROJECTS).
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 LIGHTDASH_DRY_RUN environment variable.
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.LIGHTDASH_DRY_RUN;
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 LIGHTDASH_AUDIT_LOG, or undefined to use stderr.
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.LIGHTDASH_AUDIT_LOG || undefined;
73
+ return process.env.LIGHTDASH_TOOLS_AUDIT_LOG || undefined;
74
74
  }
75
75
 
76
76
  /**
@@ -29,17 +29,17 @@ describe('registerToolSafe', () => {
29
29
  setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
30
30
  setStaticAllowedProjectUuids([]);
31
31
  setDryRunMode(false);
32
- process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
32
+ process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
33
33
  delete process.env.LIGHTDASH_TOOLS_ALLOWED_PROJECTS;
34
- delete process.env.LIGHTDASH_DRY_RUN;
34
+ delete process.env.LIGHTDASH_TOOLS_DRY_RUN;
35
35
  });
36
36
 
37
37
  afterEach(() => {
38
- delete process.env.LIGHTDASH_TOOL_SAFETY_MODE;
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.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
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.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
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.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
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.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
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.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
348
+ process.env.LIGHTDASH_TOOLS_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
329
349
 
330
350
  registerToolSafe(
331
351
  mockServer,
@@ -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. 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.
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';
@@ -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 LIGHTDASH_TOOL_SAFETY_MODE.`,
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.