Skip to content

Commit e06d98f

Browse files
committed
fix: bound BigQuery schema discovery
Signed-off-by: kahirokunn <okinakahiro@gmail.com>
1 parent ba11000 commit e06d98f

7 files changed

Lines changed: 728 additions & 5 deletions

File tree

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Model Context Protocol (MCP) server for integrating Redash with AI assistants li
1212
- List available queries and dashboards as resources
1313
- Execute queries and retrieve results
1414
- Execute saved parameterized queries with typed values and saved defaults
15+
- Explore large BigQuery schemas safely with dataset, table, and column pagination
1516
- Create and manage queries (create, update, archive)
1617
- Manage query parameters, dashboard parameters, and widget parameter mappings
1718
- Inspect and update dashboard widget layouts and grid positions
@@ -331,6 +332,51 @@ Published images are signed with keyless cosign.
331332
- `execute_adhoc_query`: Execute an ad-hoc query without saving it to Redash
332333
- `get_query_results_csv`: Get query results in CSV format (supports optional refresh for latest data)
333334

335+
### Schema Discovery
336+
337+
- `get_schema`: Get the complete schema for a non-BigQuery data source. BigQuery is intentionally blocked because Redash materializes its complete cached schema in memory.
338+
- `list_bigquery_datasets`: List datasets from a Redash BigQuery data source, with at most 100 results per page.
339+
- `list_bigquery_tables`: List tables in one BigQuery dataset, with at most 100 results per page.
340+
- `get_bigquery_table_schema`: Get column metadata for one BigQuery table, with at most 100 results per page.
341+
342+
BigQuery discovery always runs through the configured Redash data source. The MCP server does not connect to BigQuery directly or require separate Google Cloud credentials.
343+
344+
Start by listing datasets for the Redash data source. `projectId` and `location` are normally read from the Redash data source, but can be supplied when the API key cannot read those options:
345+
346+
```json
347+
{
348+
"dataSourceId": 4,
349+
"location": "asia-northeast1",
350+
"page": 1,
351+
"pageSize": 25
352+
}
353+
```
354+
355+
Then list tables in the dataset you want to use:
356+
357+
```json
358+
{
359+
"dataSourceId": 4,
360+
"dataset": "analytics",
361+
"page": 1,
362+
"pageSize": 25
363+
}
364+
```
365+
366+
Finally, inspect only the required table:
367+
368+
```json
369+
{
370+
"dataSourceId": 4,
371+
"dataset": "analytics",
372+
"table": "orders",
373+
"page": 1,
374+
"pageSize": 100
375+
}
376+
```
377+
378+
Each tool queries BigQuery `INFORMATION_SCHEMA` through Redash with `LIMIT pageSize + 1`. The extra row is used to return `hasMore` and `nextPage` without loading the remaining metadata. The Redash BigQuery data source must use GoogleSQL, and its configured query location must match the dataset location.
379+
334380
### Dashboard Management
335381
- `list_dashboards`: List all available dashboards
336382
- `get_dashboard`: Get dashboard details and visualizations
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { jest } from '@jest/globals';
2+
import {
3+
BigQuerySchemaService,
4+
getBigQueryTableSchemaSchema,
5+
listBigQueryDatasetsSchema,
6+
listBigQueryTablesSchema,
7+
type SchemaDiscoveryClient,
8+
} from '../bigQuerySchema.js';
9+
import type { RedashQueryResult } from '../redashClient.js';
10+
11+
function queryResult(rows: Array<Record<string, unknown>>): RedashQueryResult {
12+
return {
13+
data: {
14+
columns: [],
15+
rows,
16+
},
17+
} as unknown as RedashQueryResult;
18+
}
19+
20+
function createMockClient() {
21+
return {
22+
getDataSource: jest.fn<any>(),
23+
getDataSources: jest.fn<any>(),
24+
executeAdhocQuery: jest.fn<any>(),
25+
getSchema: jest.fn<any>(),
26+
};
27+
}
28+
29+
describe('BigQuerySchemaService', () => {
30+
let client: ReturnType<typeof createMockClient>;
31+
let service: BigQuerySchemaService;
32+
33+
beforeEach(() => {
34+
client = createMockClient();
35+
client.getDataSource.mockResolvedValue({
36+
id: 4,
37+
name: 'BigQuery',
38+
type: 'bigquery',
39+
options: {
40+
projectId: 'kanabell-prod',
41+
location: 'asia-northeast1',
42+
},
43+
});
44+
client.getDataSources.mockResolvedValue([]);
45+
service = new BigQuerySchemaService(client as unknown as SchemaDiscoveryClient);
46+
});
47+
48+
it('blocks the unbounded Redash schema endpoint for BigQuery', async () => {
49+
await expect(service.getSchema(4)).rejects.toThrow(
50+
'Use list_bigquery_datasets, then list_bigquery_tables, and finally get_bigquery_table_schema'
51+
);
52+
expect(client.getSchema).not.toHaveBeenCalled();
53+
});
54+
55+
it('keeps the existing schema endpoint for non-BigQuery data sources', async () => {
56+
client.getDataSource.mockResolvedValue({ id: 2, name: 'PostgreSQL', type: 'pg' });
57+
client.getSchema.mockResolvedValue({ schema: [] });
58+
59+
await expect(service.getSchema(2)).resolves.toEqual({ schema: [] });
60+
expect(client.getSchema).toHaveBeenCalledWith(2);
61+
});
62+
63+
it('falls back to the data source list when details omit the type', async () => {
64+
client.getDataSource.mockResolvedValue({ view_only: true });
65+
client.getDataSources.mockResolvedValue([
66+
{ id: 4, name: 'BigQuery', type: 'bigquery_gce', view_only: true },
67+
]);
68+
69+
await expect(service.getSchema(4)).rejects.toThrow('Unbounded BigQuery schema retrieval is disabled');
70+
expect(client.getSchema).not.toHaveBeenCalled();
71+
});
72+
73+
it('fails closed when the data source type cannot be determined', async () => {
74+
client.getDataSource.mockResolvedValue({ view_only: true });
75+
client.getDataSources.mockResolvedValue([]);
76+
77+
await expect(service.getSchema(4)).rejects.toThrow(
78+
'Unable to determine data source 4 type; refusing schema discovery for safety'
79+
);
80+
expect(client.getSchema).not.toHaveBeenCalled();
81+
});
82+
83+
it('lists one bounded page of datasets using Redash data source configuration', async () => {
84+
client.executeAdhocQuery.mockResolvedValue(queryResult([
85+
{ catalog_name: 'kanabell-prod', schema_name: 'analytics', location: 'asia-northeast1' },
86+
{ catalog_name: 'kanabell-prod', schema_name: 'raw', location: 'asia-northeast1' },
87+
{ catalog_name: 'kanabell-prod', schema_name: 'staging', location: 'asia-northeast1' },
88+
]));
89+
90+
const input = listBigQueryDatasetsSchema.parse({ dataSourceId: '4', pageSize: '2' });
91+
const result = await service.listDatasets(input);
92+
93+
expect(client.executeAdhocQuery).toHaveBeenCalledWith(
94+
expect.stringContaining('FROM `kanabell-prod`.`region-asia-northeast1`.INFORMATION_SCHEMA.SCHEMATA'),
95+
4
96+
);
97+
const query = client.executeAdhocQuery.mock.calls[0][0] as string;
98+
expect(query).toContain('LIMIT 3\nOFFSET 0');
99+
expect(result).toEqual({
100+
page: 1,
101+
pageSize: 2,
102+
hasMore: true,
103+
nextPage: 2,
104+
datasets: [
105+
{ projectId: 'kanabell-prod', dataset: 'analytics', location: 'asia-northeast1' },
106+
{ projectId: 'kanabell-prod', dataset: 'raw', location: 'asia-northeast1' },
107+
],
108+
});
109+
});
110+
111+
it('lists tables for only the requested dataset and page', async () => {
112+
client.executeAdhocQuery.mockResolvedValue(queryResult([
113+
{
114+
table_catalog: 'reporting-prod',
115+
table_schema: 'analytics',
116+
table_name: 'orders',
117+
table_type: 'BASE TABLE',
118+
creation_time: '2026-07-21T00:00:00Z',
119+
},
120+
]));
121+
122+
const input = listBigQueryTablesSchema.parse({
123+
dataSourceId: 4,
124+
projectId: 'reporting-prod',
125+
dataset: 'analytics',
126+
page: 2,
127+
pageSize: 2,
128+
});
129+
const result = await service.listTables(input);
130+
131+
const query = client.executeAdhocQuery.mock.calls[0][0] as string;
132+
expect(query).toContain('FROM `reporting-prod`.`analytics`.INFORMATION_SCHEMA.TABLES');
133+
expect(query).toContain('LIMIT 3\nOFFSET 2');
134+
expect(result.hasMore).toBe(false);
135+
expect(result.nextPage).toBeNull();
136+
expect(result.tables[0]).toMatchObject({ name: 'orders', type: 'BASE TABLE' });
137+
});
138+
139+
it('accepts Redash query_result wrappers and defaults an empty configured location to US', async () => {
140+
client.getDataSource.mockResolvedValue({
141+
id: 4,
142+
name: 'BigQuery',
143+
type: 'bigquery',
144+
options: { projectId: 'kanabell-prod', location: '' },
145+
});
146+
client.executeAdhocQuery.mockResolvedValue({
147+
query_result: queryResult([
148+
{ catalog_name: 'kanabell-prod', schema_name: 'analytics', location: 'US' },
149+
]),
150+
});
151+
152+
const result = await service.listDatasets(listBigQueryDatasetsSchema.parse({ dataSourceId: 4 }));
153+
154+
const query = client.executeAdhocQuery.mock.calls[0][0] as string;
155+
expect(query).toContain('FROM `kanabell-prod`.`region-us`.INFORMATION_SCHEMA.SCHEMATA');
156+
expect(result.datasets).toEqual([
157+
{ projectId: 'kanabell-prod', dataset: 'analytics', location: 'US' },
158+
]);
159+
});
160+
161+
it('gets bounded column metadata for one table and escapes its name', async () => {
162+
client.executeAdhocQuery.mockResolvedValue(queryResult([
163+
{
164+
column_name: 'order_id',
165+
ordinal_position: 1,
166+
data_type: 'STRING',
167+
is_nullable: 'NO',
168+
is_partitioning_column: 'YES',
169+
clustering_ordinal_position: null,
170+
},
171+
]));
172+
173+
const input = getBigQueryTableSchemaSchema.parse({
174+
dataSourceId: 4,
175+
dataset: 'analytics',
176+
table: "order's",
177+
pageSize: 100,
178+
});
179+
const result = await service.getTableSchema(input);
180+
181+
const query = client.executeAdhocQuery.mock.calls[0][0] as string;
182+
expect(query).toContain('FROM `kanabell-prod`.`analytics`.INFORMATION_SCHEMA.COLUMNS');
183+
expect(query).toContain("WHERE table_name = 'order\\'s'");
184+
expect(query).toContain('LIMIT 101\nOFFSET 0');
185+
expect(result.columns).toEqual([
186+
{
187+
name: 'order_id',
188+
position: 1,
189+
type: 'STRING',
190+
nullable: false,
191+
isPartitioningColumn: true,
192+
clusteringPosition: null,
193+
},
194+
]);
195+
});
196+
197+
it('rejects BigQuery-specific discovery for a different data source type', async () => {
198+
client.getDataSource.mockResolvedValue({ id: 2, name: 'MySQL', type: 'mysql' });
199+
const input = listBigQueryTablesSchema.parse({
200+
dataSourceId: 2,
201+
dataset: 'analytics',
202+
});
203+
204+
await expect(service.listTables(input)).rejects.toThrow('is not a BigQuery data source');
205+
expect(client.executeAdhocQuery).not.toHaveBeenCalled();
206+
});
207+
208+
it('rejects unsafe datasets and oversized pages before executing a query', () => {
209+
expect(() => listBigQueryTablesSchema.parse({
210+
dataSourceId: 4,
211+
dataset: 'analytics` UNION ALL SELECT secret',
212+
})).toThrow();
213+
expect(() => listBigQueryDatasetsSchema.parse({
214+
dataSourceId: 4,
215+
pageSize: 101,
216+
})).toThrow();
217+
});
218+
});

src/__tests__/mcpServer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ describe("Redash MCP server", () => {
2121
jest.restoreAllMocks();
2222
});
2323

24-
it("defines all 67 public tools", () => {
25-
expect(toolDefinitions).toHaveLength(67);
24+
it("defines all 70 public tools", () => {
25+
expect(toolDefinitions).toHaveLength(70);
2626
});
2727

2828
it("advertises the published package version", async () => {

src/__tests__/redashClient.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,23 @@ describe('RedashClient', () => {
579579
});
580580
});
581581

582+
describe('getDataSource', () => {
583+
it('should fetch one data source with its details', async () => {
584+
const mockDataSource = {
585+
id: 4,
586+
name: 'BigQuery',
587+
type: 'bigquery',
588+
options: { projectId: 'kanabell-prod', location: 'asia-northeast1' },
589+
};
590+
mockAxiosInstance.get.mockResolvedValue({ data: mockDataSource });
591+
592+
const result = await client.getDataSource(4);
593+
594+
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/api/data_sources/4');
595+
expect(result).toEqual(mockDataSource);
596+
});
597+
});
598+
582599
describe('getDashboards', () => {
583600
it('should fetch dashboards', async () => {
584601
const mockResponse = {

0 commit comments

Comments
 (0)