@lightdash-tools/mcp 0.2.1 → 0.2.4
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 +44 -1
- package/dist/bin.d.ts +1 -1
- package/dist/bin.js +29 -7
- package/dist/config.d.ts +13 -0
- package/dist/config.js +23 -0
- package/dist/tools/shared.js +9 -2
- package/dist/tools/shared.test.js +40 -3
- package/package.json +4 -3
- package/src/bin.ts +32 -6
- package/src/config.ts +25 -0
- package/src/tools/shared.test.ts +67 -3
- package/src/tools/shared.ts +11 -3
package/README.md
CHANGED
|
@@ -28,6 +28,10 @@ npm install -g @lightdash-tools/mcp
|
|
|
28
28
|
- `LIGHTDASH_URL` — Lightdash instance base URL (e.g. `https://app.lightdash.cloud`).
|
|
29
29
|
- `LIGHTDASH_API_KEY` — Personal access token or API key.
|
|
30
30
|
|
|
31
|
+
### Optional (both modes)
|
|
32
|
+
|
|
33
|
+
- `LIGHTDASH_TOOL_SAFETY_MODE` — Safety mode for dynamic enforcement (`read-only`, `write-idempotent`, `write-destructive`). See [Safety Modes](#safety-modes) for details.
|
|
34
|
+
|
|
31
35
|
### Streamable HTTP only
|
|
32
36
|
|
|
33
37
|
- `MCP_HTTP_PORT` — Port for the HTTP server (default: `3100`).
|
|
@@ -44,6 +48,12 @@ For use with Claude Desktop or IDEs, use `npx`:
|
|
|
44
48
|
npx @lightdash-tools/mcp
|
|
45
49
|
```
|
|
46
50
|
|
|
51
|
+
To hide destructive tools from the agent:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @lightdash-tools/mcp --safety-mode write-idempotent
|
|
55
|
+
```
|
|
56
|
+
|
|
47
57
|
Or if installed globally:
|
|
48
58
|
|
|
49
59
|
```bash
|
|
@@ -64,7 +74,40 @@ With auth disabled (default), any client can call the endpoint. With `MCP_AUTH_E
|
|
|
64
74
|
|
|
65
75
|
## Tools
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
The server registers the following tools (names prefixed with `lightdash_tools__`):
|
|
78
|
+
|
|
79
|
+
- **Projects**: `list_projects`, `get_project`, `validate_project`, `get_validation_results`
|
|
80
|
+
- **Explores**: `list_explores`, `get_explore`, `list_dimensions`, `get_field_lineage`
|
|
81
|
+
- **Charts**: `list_charts`, `list_charts_as_code`, `upsert_chart_as_code`
|
|
82
|
+
- **Dashboards**: `list_dashboards`
|
|
83
|
+
- **Spaces**: `list_spaces`, `get_space`
|
|
84
|
+
- **Users**: `list_organization_members`, `get_member`, `delete_member`
|
|
85
|
+
- **Groups**: `list_groups`, `get_group`
|
|
86
|
+
- **Metrics**: `list_metrics`
|
|
87
|
+
- **Schedulers**: `list_schedulers`
|
|
88
|
+
- **Tags**: `list_tags`
|
|
89
|
+
- **Query**: `compile_query`
|
|
90
|
+
- **Content**: `search_content`
|
|
91
|
+
|
|
92
|
+
### CLI Options
|
|
93
|
+
|
|
94
|
+
- `--http` — Run as HTTP server instead of Stdio.
|
|
95
|
+
- `--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).
|
|
96
|
+
|
|
97
|
+
## Safety Modes
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
- `read-only`: Only allows non-modifying tools (e.g., `list_*`, `get_*`).
|
|
102
|
+
- `write-idempotent`: Allows read tools and non-destructive writes (e.g., `upsert_chart_as_code`).
|
|
103
|
+
- `write-destructive` (default): Allows all tools, including destructive ones (e.g., `delete_member`).
|
|
104
|
+
|
|
105
|
+
### Enforcement Layers
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
When a tool is disabled via dynamic enforcement, the server will return a descriptive error message if an agent attempts to call it.
|
|
68
111
|
|
|
69
112
|
### Destructive tools
|
|
70
113
|
|
package/dist/bin.d.ts
CHANGED
package/dist/bin.js
CHANGED
|
@@ -36,10 +36,32 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
return result;
|
|
37
37
|
};
|
|
38
38
|
})();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
const commander_1 = require("commander");
|
|
41
|
+
const common_1 = require("@lightdash-tools/common");
|
|
42
|
+
const config_js_1 = require("./config.js");
|
|
43
|
+
const program = new commander_1.Command();
|
|
44
|
+
program
|
|
45
|
+
.name('lightdash-mcp')
|
|
46
|
+
.description('MCP server for Lightdash AI')
|
|
47
|
+
.version('0.2.3')
|
|
48
|
+
.option('--http', 'Run as HTTP server instead of Stdio')
|
|
49
|
+
.option('--safety-mode <mode>', 'Filter registered tools by safety mode (read-only, write-idempotent, write-destructive)')
|
|
50
|
+
.action((options) => {
|
|
51
|
+
if (options.safetyMode) {
|
|
52
|
+
if (Object.values(common_1.SafetyMode).includes(options.safetyMode)) {
|
|
53
|
+
(0, config_js_1.setStaticSafetyMode)(options.safetyMode);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.error(`Invalid safety mode: ${options.safetyMode}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (options.http) {
|
|
61
|
+
void Promise.resolve().then(() => __importStar(require('./http.js')));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
void Promise.resolve().then(() => __importStar(require('./index.js')));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
program.parse(process.argv);
|
package/dist/config.d.ts
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { LightdashClient } from '@lightdash-tools/client';
|
|
6
6
|
import type { PartialLightdashClientConfig } from '@lightdash-tools/client';
|
|
7
|
+
import type { SafetyMode } from '@lightdash-tools/common';
|
|
8
|
+
/**
|
|
9
|
+
* Gets the safety mode for dynamic enforcement.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getSafetyMode(): SafetyMode;
|
|
12
|
+
/**
|
|
13
|
+
* Gets the safety mode for static tool filtering (binding).
|
|
14
|
+
*/
|
|
15
|
+
export declare function getStaticSafetyMode(): SafetyMode | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Sets the static safety mode (from CLI).
|
|
18
|
+
*/
|
|
19
|
+
export declare function setStaticSafetyMode(mode: SafetyMode): void;
|
|
7
20
|
/**
|
|
8
21
|
* Builds a LightdashClient from environment variables (and optional overrides).
|
|
9
22
|
* Throws if LIGHTDASH_URL or LIGHTDASH_API_KEY are missing.
|
package/dist/config.js
CHANGED
|
@@ -4,8 +4,31 @@
|
|
|
4
4
|
* Uses same env vars as CLI: LIGHTDASH_URL, LIGHTDASH_API_KEY.
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.getSafetyMode = getSafetyMode;
|
|
8
|
+
exports.getStaticSafetyMode = getStaticSafetyMode;
|
|
9
|
+
exports.setStaticSafetyMode = setStaticSafetyMode;
|
|
7
10
|
exports.getClient = getClient;
|
|
8
11
|
const client_1 = require("@lightdash-tools/client");
|
|
12
|
+
const common_1 = require("@lightdash-tools/common");
|
|
13
|
+
let globalStaticSafetyMode;
|
|
14
|
+
/**
|
|
15
|
+
* Gets the safety mode for dynamic enforcement.
|
|
16
|
+
*/
|
|
17
|
+
function getSafetyMode() {
|
|
18
|
+
return (0, common_1.getSafetyModeFromEnv)();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Gets the safety mode for static tool filtering (binding).
|
|
22
|
+
*/
|
|
23
|
+
function getStaticSafetyMode() {
|
|
24
|
+
return globalStaticSafetyMode;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Sets the static safety mode (from CLI).
|
|
28
|
+
*/
|
|
29
|
+
function setStaticSafetyMode(mode) {
|
|
30
|
+
globalStaticSafetyMode = mode;
|
|
31
|
+
}
|
|
9
32
|
/**
|
|
10
33
|
* Builds a LightdashClient from environment variables (and optional overrides).
|
|
11
34
|
* Throws if LIGHTDASH_URL or LIGHTDASH_API_KEY are missing.
|
package/dist/tools/shared.js
CHANGED
|
@@ -17,6 +17,7 @@ exports.registerToolSafe = registerToolSafe;
|
|
|
17
17
|
exports.wrapTool = wrapTool;
|
|
18
18
|
const common_1 = require("@lightdash-tools/common");
|
|
19
19
|
const errors_js_1 = require("../errors.js");
|
|
20
|
+
const config_js_1 = require("../config.js");
|
|
20
21
|
/** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
|
|
21
22
|
exports.TOOL_PREFIX = 'lightdash_tools__';
|
|
22
23
|
// Re-export presets for convenience and backward compatibility in tools
|
|
@@ -35,7 +36,13 @@ function registerToolSafe(server, shortName, options, handler) {
|
|
|
35
36
|
var _a, _b;
|
|
36
37
|
const name = exports.TOOL_PREFIX + shortName;
|
|
37
38
|
const annotations = mergeAnnotations(options.annotations);
|
|
38
|
-
|
|
39
|
+
// Static Filtering: Skip registration if not allowed in static safety mode
|
|
40
|
+
const staticMode = (0, config_js_1.getStaticSafetyMode)();
|
|
41
|
+
if (staticMode && !(0, common_1.isAllowed)(staticMode, annotations)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Dynamic Enforcement: Wrap handler if not allowed in current safety mode (env)
|
|
45
|
+
const mode = (0, config_js_1.getSafetyMode)();
|
|
39
46
|
const isToolAllowed = (0, common_1.isAllowed)(mode, annotations);
|
|
40
47
|
// If not allowed, wrap handler to return an error and update description
|
|
41
48
|
let finalHandler = handler;
|
|
@@ -47,7 +54,7 @@ function registerToolSafe(server, shortName, options, handler) {
|
|
|
47
54
|
content: [
|
|
48
55
|
{
|
|
49
56
|
type: 'text',
|
|
50
|
-
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change
|
|
57
|
+
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change LIGHTDASH_TOOL_SAFETY_MODE.`,
|
|
51
58
|
},
|
|
52
59
|
],
|
|
53
60
|
isError: true,
|
|
@@ -12,13 +12,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
const vitest_1 = require("vitest");
|
|
13
13
|
const shared_1 = require("./shared");
|
|
14
14
|
const common_1 = require("@lightdash-tools/common");
|
|
15
|
+
const config_js_1 = require("../config.js");
|
|
15
16
|
(0, vitest_1.describe)('registerToolSafe', () => {
|
|
16
17
|
const mockServer = {
|
|
17
18
|
registerTool: vitest_1.vi.fn(),
|
|
18
19
|
};
|
|
19
20
|
const mockHandler = vitest_1.vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'success' }] });
|
|
20
21
|
(0, vitest_1.it)('should allow read-only tool in read-only mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
21
|
-
process.env.
|
|
22
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
|
|
22
23
|
(0, shared_1.registerToolSafe)(mockServer, 'test_tool', {
|
|
23
24
|
description: 'Test description',
|
|
24
25
|
inputSchema: {},
|
|
@@ -32,7 +33,7 @@ const common_1 = require("@lightdash-tools/common");
|
|
|
32
33
|
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
33
34
|
}));
|
|
34
35
|
(0, vitest_1.it)('should block destructive tool in read-only mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
35
|
-
process.env.
|
|
36
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.READ_ONLY;
|
|
36
37
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_tool', {
|
|
37
38
|
description: 'Delete something',
|
|
38
39
|
inputSchema: {},
|
|
@@ -45,7 +46,7 @@ const common_1 = require("@lightdash-tools/common");
|
|
|
45
46
|
(0, vitest_1.expect)(result.content[0].text).toContain('disabled in read-only mode');
|
|
46
47
|
}));
|
|
47
48
|
(0, vitest_1.it)('should allow destructive tool in write-destructive mode', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
48
|
-
process.env.
|
|
49
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = common_1.SafetyMode.WRITE_DESTRUCTIVE;
|
|
49
50
|
(0, shared_1.registerToolSafe)(mockServer, 'delete_tool_2', {
|
|
50
51
|
description: 'Delete something 2',
|
|
51
52
|
inputSchema: {},
|
|
@@ -56,4 +57,40 @@ const common_1 = require("@lightdash-tools/common");
|
|
|
56
57
|
const result = yield handler({});
|
|
57
58
|
(0, vitest_1.expect)(result.content[0].text).toBe('success');
|
|
58
59
|
}));
|
|
60
|
+
(0, vitest_1.describe)('static filtering (safety-mode)', () => {
|
|
61
|
+
(0, vitest_1.it)('should skip registration if tool is more permissive than binded mode', () => {
|
|
62
|
+
// Set binded mode to READ_ONLY
|
|
63
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.READ_ONLY);
|
|
64
|
+
mockServer.registerTool.mockClear();
|
|
65
|
+
(0, shared_1.registerToolSafe)(mockServer, 'destructive_tool_static', {
|
|
66
|
+
description: 'Destructive',
|
|
67
|
+
inputSchema: {},
|
|
68
|
+
annotations: shared_1.WRITE_DESTRUCTIVE,
|
|
69
|
+
}, mockHandler);
|
|
70
|
+
(0, vitest_1.expect)(mockServer.registerTool).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
(0, vitest_1.it)('should allow registration if tool matches binded mode', () => {
|
|
73
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.READ_ONLY);
|
|
74
|
+
mockServer.registerTool.mockClear();
|
|
75
|
+
(0, shared_1.registerToolSafe)(mockServer, 'readonly_tool_static', {
|
|
76
|
+
description: 'Read-only',
|
|
77
|
+
inputSchema: {},
|
|
78
|
+
annotations: shared_1.READ_ONLY_DEFAULT,
|
|
79
|
+
}, mockHandler);
|
|
80
|
+
(0, vitest_1.expect)(mockServer.registerTool).toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
(0, vitest_1.it)('should allow everything if binded mode is undefined', () => {
|
|
83
|
+
// This is a bit tricky since it's a global. We might need a way to reset it.
|
|
84
|
+
// For now, let's assume we can just pass a permissive mode or it was undefined initially.
|
|
85
|
+
// Since we don't have a reset, let's just test that it works when set to DESTRUCTIVE.
|
|
86
|
+
(0, config_js_1.setStaticSafetyMode)(common_1.SafetyMode.WRITE_DESTRUCTIVE);
|
|
87
|
+
mockServer.registerTool.mockClear();
|
|
88
|
+
(0, shared_1.registerToolSafe)(mockServer, 'any_tool_static', {
|
|
89
|
+
description: 'Any',
|
|
90
|
+
inputSchema: {},
|
|
91
|
+
annotations: shared_1.WRITE_DESTRUCTIVE,
|
|
92
|
+
}, mockHandler);
|
|
93
|
+
(0, vitest_1.expect)(mockServer.registerTool).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
59
96
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash-tools/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "MCP server and utilities for Lightdash AI.",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
15
|
+
"commander": "^14.0.3",
|
|
15
16
|
"zod": "^4.3.6",
|
|
16
|
-
"@lightdash-tools/client": "0.2.
|
|
17
|
-
"@lightdash-tools/common": "0.2.
|
|
17
|
+
"@lightdash-tools/client": "0.2.4",
|
|
18
|
+
"@lightdash-tools/common": "0.2.4"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
20
21
|
"@types/node": "^25.2.3"
|
package/src/bin.ts
CHANGED
|
@@ -3,10 +3,36 @@
|
|
|
3
3
|
* MCP server CLI entrypoint.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { SafetyMode } from '@lightdash-tools/common';
|
|
8
|
+
import { setStaticSafetyMode } from './config.js';
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('lightdash-mcp')
|
|
14
|
+
.description('MCP server for Lightdash AI')
|
|
15
|
+
.version('0.2.3')
|
|
16
|
+
.option('--http', 'Run as HTTP server instead of Stdio')
|
|
17
|
+
.option(
|
|
18
|
+
'--safety-mode <mode>',
|
|
19
|
+
'Filter registered tools by safety mode (read-only, write-idempotent, write-destructive)',
|
|
20
|
+
)
|
|
21
|
+
.action((options) => {
|
|
22
|
+
if (options.safetyMode) {
|
|
23
|
+
if (Object.values(SafetyMode).includes(options.safetyMode)) {
|
|
24
|
+
setStaticSafetyMode(options.safetyMode as SafetyMode);
|
|
25
|
+
} else {
|
|
26
|
+
console.error(`Invalid safety mode: ${options.safetyMode}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.http) {
|
|
32
|
+
void import('./http.js');
|
|
33
|
+
} else {
|
|
34
|
+
void import('./index.js');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
program.parse(process.argv);
|
package/src/config.ts
CHANGED
|
@@ -5,6 +5,31 @@
|
|
|
5
5
|
|
|
6
6
|
import { LightdashClient, mergeConfig } from '@lightdash-tools/client';
|
|
7
7
|
import type { PartialLightdashClientConfig } from '@lightdash-tools/client';
|
|
8
|
+
import { getSafetyModeFromEnv } from '@lightdash-tools/common';
|
|
9
|
+
import type { SafetyMode } from '@lightdash-tools/common';
|
|
10
|
+
|
|
11
|
+
let globalStaticSafetyMode: SafetyMode | undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gets the safety mode for dynamic enforcement.
|
|
15
|
+
*/
|
|
16
|
+
export function getSafetyMode(): SafetyMode {
|
|
17
|
+
return getSafetyModeFromEnv();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Gets the safety mode for static tool filtering (binding).
|
|
22
|
+
*/
|
|
23
|
+
export function getStaticSafetyMode(): SafetyMode | undefined {
|
|
24
|
+
return globalStaticSafetyMode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sets the static safety mode (from CLI).
|
|
29
|
+
*/
|
|
30
|
+
export function setStaticSafetyMode(mode: SafetyMode): void {
|
|
31
|
+
globalStaticSafetyMode = mode;
|
|
32
|
+
}
|
|
8
33
|
|
|
9
34
|
/**
|
|
10
35
|
* Builds a LightdashClient from environment variables (and optional overrides).
|
package/src/tools/shared.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { registerToolSafe, READ_ONLY_DEFAULT, WRITE_DESTRUCTIVE } from './shared';
|
|
3
3
|
import { SafetyMode } from '@lightdash-tools/common';
|
|
4
|
+
import { setStaticSafetyMode } from '../config.js';
|
|
4
5
|
|
|
5
6
|
describe('registerToolSafe', () => {
|
|
6
7
|
const mockServer = {
|
|
@@ -10,7 +11,7 @@ describe('registerToolSafe', () => {
|
|
|
10
11
|
const mockHandler = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'success' }] });
|
|
11
12
|
|
|
12
13
|
it('should allow read-only tool in read-only mode', async () => {
|
|
13
|
-
process.env.
|
|
14
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
|
|
14
15
|
|
|
15
16
|
registerToolSafe(
|
|
16
17
|
mockServer,
|
|
@@ -34,7 +35,7 @@ describe('registerToolSafe', () => {
|
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
it('should block destructive tool in read-only mode', async () => {
|
|
37
|
-
process.env.
|
|
38
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
|
|
38
39
|
|
|
39
40
|
registerToolSafe(
|
|
40
41
|
mockServer,
|
|
@@ -57,7 +58,7 @@ describe('registerToolSafe', () => {
|
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
it('should allow destructive tool in write-destructive mode', async () => {
|
|
60
|
-
process.env.
|
|
61
|
+
process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
|
|
61
62
|
|
|
62
63
|
registerToolSafe(
|
|
63
64
|
mockServer,
|
|
@@ -77,4 +78,67 @@ describe('registerToolSafe', () => {
|
|
|
77
78
|
const result = await handler({});
|
|
78
79
|
expect(result.content[0].text).toBe('success');
|
|
79
80
|
});
|
|
81
|
+
|
|
82
|
+
describe('static filtering (safety-mode)', () => {
|
|
83
|
+
it('should skip registration if tool is more permissive than binded mode', () => {
|
|
84
|
+
// Set binded mode to READ_ONLY
|
|
85
|
+
setStaticSafetyMode(SafetyMode.READ_ONLY);
|
|
86
|
+
|
|
87
|
+
mockServer.registerTool.mockClear();
|
|
88
|
+
|
|
89
|
+
registerToolSafe(
|
|
90
|
+
mockServer,
|
|
91
|
+
'destructive_tool_static',
|
|
92
|
+
{
|
|
93
|
+
description: 'Destructive',
|
|
94
|
+
inputSchema: {},
|
|
95
|
+
annotations: WRITE_DESTRUCTIVE,
|
|
96
|
+
},
|
|
97
|
+
mockHandler,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(mockServer.registerTool).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should allow registration if tool matches binded mode', () => {
|
|
104
|
+
setStaticSafetyMode(SafetyMode.READ_ONLY);
|
|
105
|
+
|
|
106
|
+
mockServer.registerTool.mockClear();
|
|
107
|
+
|
|
108
|
+
registerToolSafe(
|
|
109
|
+
mockServer,
|
|
110
|
+
'readonly_tool_static',
|
|
111
|
+
{
|
|
112
|
+
description: 'Read-only',
|
|
113
|
+
inputSchema: {},
|
|
114
|
+
annotations: READ_ONLY_DEFAULT,
|
|
115
|
+
},
|
|
116
|
+
mockHandler,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(mockServer.registerTool).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
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.
|
|
126
|
+
setStaticSafetyMode(SafetyMode.WRITE_DESTRUCTIVE);
|
|
127
|
+
|
|
128
|
+
mockServer.registerTool.mockClear();
|
|
129
|
+
|
|
130
|
+
registerToolSafe(
|
|
131
|
+
mockServer,
|
|
132
|
+
'any_tool_static',
|
|
133
|
+
{
|
|
134
|
+
description: 'Any',
|
|
135
|
+
inputSchema: {},
|
|
136
|
+
annotations: WRITE_DESTRUCTIVE,
|
|
137
|
+
},
|
|
138
|
+
mockHandler,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(mockServer.registerTool).toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
80
144
|
});
|
package/src/tools/shared.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { LightdashClient } from '@lightdash-tools/client';
|
|
6
|
-
import { isAllowed,
|
|
6
|
+
import { isAllowed, READ_ONLY_DEFAULT } from '@lightdash-tools/common';
|
|
7
7
|
import type { ToolAnnotations } from '@lightdash-tools/common';
|
|
8
8
|
import type { z } from 'zod';
|
|
9
9
|
import { toMcpErrorMessage } from '../errors.js';
|
|
10
|
+
import { getStaticSafetyMode, getSafetyMode } from '../config.js';
|
|
10
11
|
|
|
11
12
|
/** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
|
|
12
13
|
export const TOOL_PREFIX = 'lightdash_tools__';
|
|
@@ -49,8 +50,15 @@ export function registerToolSafe(
|
|
|
49
50
|
): void {
|
|
50
51
|
const name = TOOL_PREFIX + shortName;
|
|
51
52
|
const annotations = mergeAnnotations(options.annotations);
|
|
52
|
-
const mode = getSafetyModeFromEnv();
|
|
53
53
|
|
|
54
|
+
// Static Filtering: Skip registration if not allowed in static safety mode
|
|
55
|
+
const staticMode = getStaticSafetyMode();
|
|
56
|
+
if (staticMode && !isAllowed(staticMode, annotations)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Dynamic Enforcement: Wrap handler if not allowed in current safety mode (env)
|
|
61
|
+
const mode = getSafetyMode();
|
|
54
62
|
const isToolAllowed = isAllowed(mode, annotations);
|
|
55
63
|
|
|
56
64
|
// If not allowed, wrap handler to return an error and update description
|
|
@@ -63,7 +71,7 @@ export function registerToolSafe(
|
|
|
63
71
|
content: [
|
|
64
72
|
{
|
|
65
73
|
type: 'text',
|
|
66
|
-
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change
|
|
74
|
+
text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change LIGHTDASH_TOOL_SAFETY_MODE.`,
|
|
67
75
|
},
|
|
68
76
|
],
|
|
69
77
|
isError: true,
|