@lightdash-tools/mcp 0.1.2 → 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.
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /**
3
+ * MCP tools: tags (list).
4
+ */
5
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
6
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
7
+ return new (P || (P = Promise))(function (resolve, reject) {
8
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
9
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
10
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
11
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
12
+ });
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.registerTagsTools = registerTagsTools;
16
+ const zod_1 = require("zod");
17
+ const shared_js_1 = require("./shared.js");
18
+ function registerTagsTools(server, client) {
19
+ (0, shared_js_1.registerToolSafe)(server, 'list_tags', {
20
+ title: 'List tags',
21
+ description: 'List all tags in a project',
22
+ inputSchema: { projectUuid: zod_1.z.string().describe('Project UUID') },
23
+ annotations: shared_js_1.READ_ONLY_DEFAULT,
24
+ }, (0, shared_js_1.wrapTool)(client, (c) => (_a) => __awaiter(this, [_a], void 0, function* ({ projectUuid }) {
25
+ const result = yield c.v1.tags.listTags(projectUuid);
26
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
27
+ })));
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash-tools/mcp",
3
- "version": "0.1.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,8 +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.1.2"
17
+ "@lightdash-tools/client": "0.2.4",
18
+ "@lightdash-tools/common": "0.2.4"
17
19
  },
18
20
  "devDependencies": {
19
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
- const args = process.argv.slice(2);
6
+ import { Command } from 'commander';
7
+ import { SafetyMode } from '@lightdash-tools/common';
8
+ import { setStaticSafetyMode } from './config.js';
7
9
 
8
- if (args.includes('--http')) {
9
- void import('./http.js');
10
- } else {
11
- void import('./index.js');
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).
@@ -0,0 +1,44 @@
1
+ /**
2
+ * MCP tools: content (search).
3
+ */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import type { LightdashClient } from '@lightdash-tools/client';
7
+ import { z } from 'zod';
8
+ import { wrapTool, registerToolSafe, READ_ONLY_DEFAULT } from './shared.js';
9
+
10
+ export function registerContentTools(server: McpServer, client: LightdashClient): void {
11
+ registerToolSafe(
12
+ server,
13
+ 'search_content',
14
+ {
15
+ title: 'Search content',
16
+ description: 'Search for charts, dashboards, and spaces across projects',
17
+ inputSchema: {
18
+ search: z.string().describe('Search query'),
19
+ projectUuids: z.array(z.string()).optional().describe('Optional project UUIDs to filter'),
20
+ contentTypes: z
21
+ .array(z.enum(['chart', 'dashboard', 'space']))
22
+ .optional()
23
+ .describe('Optional content types to filter'),
24
+ page: z.number().optional().describe('Page number'),
25
+ pageSize: z.number().optional().describe('Page size'),
26
+ },
27
+ annotations: READ_ONLY_DEFAULT,
28
+ },
29
+ wrapTool(
30
+ client,
31
+ (c) =>
32
+ async (params: {
33
+ search: string;
34
+ projectUuids?: string[];
35
+ contentTypes?: ('chart' | 'dashboard' | 'space')[];
36
+ page?: number;
37
+ pageSize?: number;
38
+ }) => {
39
+ const result = await c.v2.content.searchContent(params);
40
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
41
+ },
42
+ ),
43
+ );
44
+ }
@@ -27,7 +27,8 @@ export function registerExploresTools(server: McpServer, client: LightdashClient
27
27
  'get_explore',
28
28
  {
29
29
  title: 'Get explore',
30
- description: 'Get an explore by project UUID and explore ID',
30
+ description:
31
+ 'Get an explore by project UUID and explore ID (includes tables, dimensions, and metrics)',
31
32
  inputSchema: {
32
33
  projectUuid: z.string().describe('Project UUID'),
33
34
  exploreId: z.string().describe('Explore ID'),
@@ -43,4 +44,55 @@ export function registerExploresTools(server: McpServer, client: LightdashClient
43
44
  },
44
45
  ),
45
46
  );
47
+ registerToolSafe(
48
+ server,
49
+ 'list_dimensions',
50
+ {
51
+ title: 'List dimensions',
52
+ description: 'List all dimensions for a specific explore',
53
+ inputSchema: {
54
+ projectUuid: z.string().describe('Project UUID'),
55
+ exploreId: z.string().describe('Explore ID'),
56
+ },
57
+ annotations: READ_ONLY_DEFAULT,
58
+ },
59
+ wrapTool(
60
+ client,
61
+ (c) =>
62
+ async ({ projectUuid, exploreId }: { projectUuid: string; exploreId: string }) => {
63
+ const result = await c.v1.explores.listDimensions(projectUuid, exploreId);
64
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
65
+ },
66
+ ),
67
+ );
68
+ registerToolSafe(
69
+ server,
70
+ 'get_field_lineage',
71
+ {
72
+ title: 'Get field lineage',
73
+ description: 'Get upstream lineage for a specific field in an explore',
74
+ inputSchema: {
75
+ projectUuid: z.string().describe('Project UUID'),
76
+ exploreId: z.string().describe('Explore ID'),
77
+ fieldId: z.string().describe('Field ID'),
78
+ },
79
+ annotations: READ_ONLY_DEFAULT,
80
+ },
81
+ wrapTool(
82
+ client,
83
+ (c) =>
84
+ async ({
85
+ projectUuid,
86
+ exploreId,
87
+ fieldId,
88
+ }: {
89
+ projectUuid: string;
90
+ exploreId: string;
91
+ fieldId: string;
92
+ }) => {
93
+ const result = await c.v1.explores.getFieldLineage(projectUuid, exploreId, fieldId);
94
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
95
+ },
96
+ ),
97
+ );
46
98
  }
@@ -12,6 +12,10 @@ import { registerUserTools } from './users.js';
12
12
  import { registerGroupTools } from './groups.js';
13
13
  import { registerQueryTools } from './query.js';
14
14
  import { registerExploresTools } from './explores.js';
15
+ import { registerMetricsTools } from './metrics.js';
16
+ import { registerSchedulersTools } from './schedulers.js';
17
+ import { registerTagsTools } from './tags.js';
18
+ import { registerContentTools } from './content.js';
15
19
 
16
20
  export function registerTools(server: McpServer, client: LightdashClient): void {
17
21
  registerProjectTools(server, client);
@@ -22,4 +26,8 @@ export function registerTools(server: McpServer, client: LightdashClient): void
22
26
  registerGroupTools(server, client);
23
27
  registerQueryTools(server, client);
24
28
  registerExploresTools(server, client);
29
+ registerMetricsTools(server, client);
30
+ registerSchedulersTools(server, client);
31
+ registerTagsTools(server, client);
32
+ registerContentTools(server, client);
25
33
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * MCP tools: metrics (list, get).
3
+ */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import type { LightdashClient } from '@lightdash-tools/client';
7
+ import { z } from 'zod';
8
+ import { wrapTool, registerToolSafe, READ_ONLY_DEFAULT } from './shared.js';
9
+
10
+ export function registerMetricsTools(server: McpServer, client: LightdashClient): void {
11
+ registerToolSafe(
12
+ server,
13
+ 'list_metrics',
14
+ {
15
+ title: 'List metrics',
16
+ description: 'List metrics in a project data catalog',
17
+ inputSchema: {
18
+ projectUuid: z.string().describe('Project UUID'),
19
+ search: z.string().optional().describe('Search query'),
20
+ page: z.number().optional().describe('Page number'),
21
+ pageSize: z.number().optional().describe('Page size'),
22
+ },
23
+ annotations: READ_ONLY_DEFAULT,
24
+ },
25
+ wrapTool(
26
+ client,
27
+ (c) =>
28
+ async ({
29
+ projectUuid,
30
+ ...params
31
+ }: {
32
+ projectUuid: string;
33
+ search?: string;
34
+ page?: number;
35
+ pageSize?: number;
36
+ }) => {
37
+ const result = await c.v1.metrics.listMetrics(projectUuid, params);
38
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
39
+ },
40
+ ),
41
+ );
42
+ }
@@ -5,7 +5,7 @@
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 { wrapTool, registerToolSafe, READ_ONLY_DEFAULT } from './shared.js';
8
+ import { wrapTool, registerToolSafe, READ_ONLY_DEFAULT, WRITE_IDEMPOTENT } from './shared.js';
9
9
 
10
10
  export function registerProjectTools(server: McpServer, client: LightdashClient): void {
11
11
  registerToolSafe(
@@ -36,4 +36,39 @@ export function registerProjectTools(server: McpServer, client: LightdashClient)
36
36
  return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
37
37
  }),
38
38
  );
39
+ registerToolSafe(
40
+ server,
41
+ 'validate_project',
42
+ {
43
+ title: 'Validate project',
44
+ description: 'Trigger a validation job for a project and return the job ID',
45
+ inputSchema: { projectUuid: z.string().describe('Project UUID') },
46
+ annotations: WRITE_IDEMPOTENT,
47
+ },
48
+ wrapTool(client, (c) => async ({ projectUuid }: { projectUuid: string }) => {
49
+ const result = await c.v1.validation.validateProject(projectUuid);
50
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
51
+ }),
52
+ );
53
+ registerToolSafe(
54
+ server,
55
+ 'get_validation_results',
56
+ {
57
+ title: 'Get validation results',
58
+ description: 'Get the latest validation results for a project',
59
+ inputSchema: {
60
+ projectUuid: z.string().describe('Project UUID'),
61
+ jobId: z.string().optional().describe('Optional job ID to get results for'),
62
+ },
63
+ annotations: READ_ONLY_DEFAULT,
64
+ },
65
+ wrapTool(
66
+ client,
67
+ (c) =>
68
+ async ({ projectUuid, jobId }: { projectUuid: string; jobId?: string }) => {
69
+ const result = await c.v1.validation.getValidationResults(projectUuid, { jobId });
70
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
71
+ },
72
+ ),
73
+ );
39
74
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * MCP tools: schedulers (list, get).
3
+ */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import type { LightdashClient } from '@lightdash-tools/client';
7
+ import { z } from 'zod';
8
+ import { wrapTool, registerToolSafe, READ_ONLY_DEFAULT } from './shared.js';
9
+
10
+ export function registerSchedulersTools(server: McpServer, client: LightdashClient): void {
11
+ registerToolSafe(
12
+ server,
13
+ 'list_schedulers',
14
+ {
15
+ title: 'List schedulers',
16
+ description: 'List scheduled deliveries in a project',
17
+ inputSchema: {
18
+ projectUuid: z.string().describe('Project UUID'),
19
+ searchQuery: z.string().optional().describe('Search query'),
20
+ page: z.number().optional().describe('Page number'),
21
+ pageSize: z.number().optional().describe('Page size'),
22
+ },
23
+ annotations: READ_ONLY_DEFAULT,
24
+ },
25
+ wrapTool(
26
+ client,
27
+ (c) =>
28
+ async ({
29
+ projectUuid,
30
+ ...params
31
+ }: {
32
+ projectUuid: string;
33
+ searchQuery?: string;
34
+ page?: number;
35
+ pageSize?: number;
36
+ }) => {
37
+ const result = await c.v1.schedulers.listSchedulers(projectUuid, params);
38
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
39
+ },
40
+ ),
41
+ );
42
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { registerToolSafe, READ_ONLY_DEFAULT, WRITE_DESTRUCTIVE } from './shared';
3
+ import { SafetyMode } from '@lightdash-tools/common';
4
+ import { setStaticSafetyMode } from '../config.js';
5
+
6
+ describe('registerToolSafe', () => {
7
+ const mockServer = {
8
+ registerTool: vi.fn(),
9
+ };
10
+
11
+ const mockHandler = vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'success' }] });
12
+
13
+ it('should allow read-only tool in read-only mode', async () => {
14
+ process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
15
+
16
+ registerToolSafe(
17
+ mockServer,
18
+ 'test_tool',
19
+ {
20
+ description: 'Test description',
21
+ inputSchema: {},
22
+ annotations: READ_ONLY_DEFAULT,
23
+ },
24
+ mockHandler,
25
+ );
26
+
27
+ expect(mockServer.registerTool).toHaveBeenCalled();
28
+ const [name, options, handler] = mockServer.registerTool.mock.calls[0];
29
+
30
+ expect(name).toContain('test_tool');
31
+ expect(options.description).toBe('Test description');
32
+
33
+ const result = await handler({});
34
+ expect(result.content[0].text).toBe('success');
35
+ });
36
+
37
+ it('should block destructive tool in read-only mode', async () => {
38
+ process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.READ_ONLY;
39
+
40
+ registerToolSafe(
41
+ mockServer,
42
+ 'delete_tool',
43
+ {
44
+ description: 'Delete something',
45
+ inputSchema: {},
46
+ annotations: WRITE_DESTRUCTIVE,
47
+ },
48
+ mockHandler,
49
+ );
50
+
51
+ const [, options, handler] = mockServer.registerTool.mock.calls[1];
52
+
53
+ expect(options.description).toContain('[DISABLED in read-only mode]');
54
+
55
+ const result = await handler({});
56
+ expect(result.isError).toBe(true);
57
+ expect(result.content[0].text).toContain('disabled in read-only mode');
58
+ });
59
+
60
+ it('should allow destructive tool in write-destructive mode', async () => {
61
+ process.env.LIGHTDASH_TOOL_SAFETY_MODE = SafetyMode.WRITE_DESTRUCTIVE;
62
+
63
+ registerToolSafe(
64
+ mockServer,
65
+ 'delete_tool_2',
66
+ {
67
+ description: 'Delete something 2',
68
+ inputSchema: {},
69
+ annotations: WRITE_DESTRUCTIVE,
70
+ },
71
+ mockHandler,
72
+ );
73
+
74
+ const [, options, handler] = mockServer.registerTool.mock.calls[2];
75
+
76
+ expect(options.description).toBe('Delete something 2');
77
+
78
+ const result = await handler({});
79
+ expect(result.content[0].text).toBe('success');
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
+ });
144
+ });
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import type { LightdashClient } from '@lightdash-tools/client';
6
+ import { isAllowed, READ_ONLY_DEFAULT } from '@lightdash-tools/common';
7
+ import type { ToolAnnotations } from '@lightdash-tools/common';
6
8
  import type { z } from 'zod';
7
9
  import { toMcpErrorMessage } from '../errors.js';
10
+ import { getStaticSafetyMode, getSafetyMode } from '../config.js';
8
11
 
9
12
  /** Prefix for all MCP tool names (disambiguation when multiple servers are connected). */
10
13
  export const TOOL_PREFIX = 'lightdash_tools__';
@@ -17,15 +20,6 @@ export type TextContent = {
17
20
  /** Tool handler type used to avoid deep instantiation with SDK/Zod. Accepts (args, extra) for SDK compatibility. */
18
21
  export type ToolHandler = (args: unknown, extra?: unknown) => Promise<TextContent>;
19
22
 
20
- /** MCP tool annotations (hints for client display and approval). See MCP spec Tool annotations. */
21
- export type ToolAnnotations = {
22
- title?: string;
23
- readOnlyHint?: boolean;
24
- destructiveHint?: boolean;
25
- idempotentHint?: boolean;
26
- openWorldHint?: boolean;
27
- };
28
-
29
23
  /** Options for registerTool; inputSchema typed as ZodRawShapeCompat for SDK compatibility. Pass annotations explicitly (e.g. READ_ONLY_DEFAULT or WRITE_IDEMPOTENT) for visibility. */
30
24
  export type ToolOptions = {
31
25
  description: string;
@@ -34,29 +28,8 @@ export type ToolOptions = {
34
28
  annotations?: ToolAnnotations;
35
29
  };
36
30
 
37
- /** Preset: read-only, non-destructive, idempotent, closed-world. Use for list/get/compile tools. */
38
- export const READ_ONLY_DEFAULT: ToolAnnotations = {
39
- readOnlyHint: true,
40
- openWorldHint: false,
41
- destructiveHint: false,
42
- idempotentHint: true,
43
- };
44
-
45
- /** Preset: write, non-destructive, idempotent (e.g. upsert by slug). Use for create/update tools. */
46
- export const WRITE_IDEMPOTENT: ToolAnnotations = {
47
- readOnlyHint: false,
48
- openWorldHint: false,
49
- destructiveHint: false,
50
- idempotentHint: true,
51
- };
52
-
53
- /** Preset: write, destructive, non-idempotent. Use for delete/remove tools; clients should prompt for user confirmation. */
54
- export const WRITE_DESTRUCTIVE: ToolAnnotations = {
55
- readOnlyHint: false,
56
- openWorldHint: false,
57
- destructiveHint: true,
58
- idempotentHint: false,
59
- };
31
+ // Re-export presets for convenience and backward compatibility in tools
32
+ export { READ_ONLY_DEFAULT, WRITE_IDEMPOTENT, WRITE_DESTRUCTIVE } from '@lightdash-tools/common';
60
33
 
61
34
  /** Internal default for mergeAnnotations; READ_ONLY_DEFAULT is the exported preset. */
62
35
  const DEFAULT_ANNOTATIONS: ToolAnnotations = READ_ONLY_DEFAULT;
@@ -77,12 +50,41 @@ export function registerToolSafe(
77
50
  ): void {
78
51
  const name = TOOL_PREFIX + shortName;
79
52
  const annotations = mergeAnnotations(options.annotations);
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();
62
+ const isToolAllowed = isAllowed(mode, annotations);
63
+
64
+ // If not allowed, wrap handler to return an error and update description
65
+ let finalHandler = handler;
66
+ let finalDescription = options.description;
67
+
68
+ if (!isToolAllowed) {
69
+ finalDescription = `[DISABLED in ${mode} mode] ${options.description}`;
70
+ finalHandler = async () => ({
71
+ content: [
72
+ {
73
+ type: 'text',
74
+ text: `Error: Tool '${name}' is disabled in ${mode} mode. To enable it, change LIGHTDASH_TOOL_SAFETY_MODE.`,
75
+ },
76
+ ],
77
+ isError: true,
78
+ });
79
+ }
80
+
80
81
  const mergedOptions: ToolOptions = {
81
82
  ...options,
83
+ description: finalDescription,
82
84
  title: options.title ?? options.annotations?.title,
83
85
  annotations,
84
86
  };
85
- (server as { registerTool: RegisterToolFn }).registerTool(name, mergedOptions, handler);
87
+ (server as { registerTool: RegisterToolFn }).registerTool(name, mergedOptions, finalHandler);
86
88
  }
87
89
 
88
90
  export function wrapTool<T>(
@@ -0,0 +1,25 @@
1
+ /**
2
+ * MCP tools: tags (list).
3
+ */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import type { LightdashClient } from '@lightdash-tools/client';
7
+ import { z } from 'zod';
8
+ import { wrapTool, registerToolSafe, READ_ONLY_DEFAULT } from './shared.js';
9
+
10
+ export function registerTagsTools(server: McpServer, client: LightdashClient): void {
11
+ registerToolSafe(
12
+ server,
13
+ 'list_tags',
14
+ {
15
+ title: 'List tags',
16
+ description: 'List all tags in a project',
17
+ inputSchema: { projectUuid: z.string().describe('Project UUID') },
18
+ annotations: READ_ONLY_DEFAULT,
19
+ },
20
+ wrapTool(client, (c) => async ({ projectUuid }: { projectUuid: string }) => {
21
+ const result = await c.v1.tags.listTags(projectUuid);
22
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
23
+ }),
24
+ );
25
+ }