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

### Schema Discovery

- `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.
- `list_bigquery_datasets`: List datasets from a Redash BigQuery data source, with at most 100 results per page.
- `list_bigquery_tables`: List tables in one BigQuery dataset, with at most 100 results per page.
- `get_bigquery_table_schema`: Get column metadata for one BigQuery table, with at most 100 results per page.

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.

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:

```json
{
"dataSourceId": 4,
"location": "asia-northeast1",
"page": 1,
"pageSize": 25
}
```

Then list tables in the dataset you want to use:

```json
{
"dataSourceId": 4,
"dataset": "analytics",
"page": 1,
"pageSize": 25
}
```

Finally, inspect only the required table:

```json
{
"dataSourceId": 4,
"dataset": "analytics",
"table": "orders",
"page": 1,
"pageSize": 100
}
```

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.

### Dashboard Management
- `list_dashboards`: List all available dashboards
- `get_dashboard`: Get dashboard details and visualizations
Expand Down
218 changes: 218 additions & 0 deletions src/__tests__/bigQuerySchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { jest } from '@jest/globals';
import {
BigQuerySchemaService,
getBigQueryTableSchemaSchema,
listBigQueryDatasetsSchema,
listBigQueryTablesSchema,
type SchemaDiscoveryClient,
} from '../bigQuerySchema.js';
import type { RedashQueryResult } from '../redashClient.js';

function queryResult(rows: Array<Record<string, unknown>>): RedashQueryResult {
return {
data: {
columns: [],
rows,
},
} as unknown as RedashQueryResult;
}

function createMockClient() {
return {
getDataSource: jest.fn<any>(),
getDataSources: jest.fn<any>(),
executeAdhocQuery: jest.fn<any>(),
getSchema: jest.fn<any>(),
};
}

describe('BigQuerySchemaService', () => {
let client: ReturnType<typeof createMockClient>;
let service: BigQuerySchemaService;

beforeEach(() => {
client = createMockClient();
client.getDataSource.mockResolvedValue({
id: 4,
name: 'BigQuery',
type: 'bigquery',
options: {
projectId: 'kanabell-prod',
location: 'asia-northeast1',
},
});
client.getDataSources.mockResolvedValue([]);
service = new BigQuerySchemaService(client as unknown as SchemaDiscoveryClient);
});

it('blocks the unbounded Redash schema endpoint for BigQuery', async () => {
await expect(service.getSchema(4)).rejects.toThrow(
'Use list_bigquery_datasets, then list_bigquery_tables, and finally get_bigquery_table_schema'
);
expect(client.getSchema).not.toHaveBeenCalled();
});

it('keeps the existing schema endpoint for non-BigQuery data sources', async () => {
client.getDataSource.mockResolvedValue({ id: 2, name: 'PostgreSQL', type: 'pg' });
client.getSchema.mockResolvedValue({ schema: [] });

await expect(service.getSchema(2)).resolves.toEqual({ schema: [] });
expect(client.getSchema).toHaveBeenCalledWith(2);
});

it('falls back to the data source list when details omit the type', async () => {
client.getDataSource.mockResolvedValue({ view_only: true });
client.getDataSources.mockResolvedValue([
{ id: 4, name: 'BigQuery', type: 'bigquery_gce', view_only: true },
]);

await expect(service.getSchema(4)).rejects.toThrow('Unbounded BigQuery schema retrieval is disabled');
expect(client.getSchema).not.toHaveBeenCalled();
});

it('fails closed when the data source type cannot be determined', async () => {
client.getDataSource.mockResolvedValue({ view_only: true });
client.getDataSources.mockResolvedValue([]);

await expect(service.getSchema(4)).rejects.toThrow(
'Unable to determine data source 4 type; refusing schema discovery for safety'
);
expect(client.getSchema).not.toHaveBeenCalled();
});

it('lists one bounded page of datasets using Redash data source configuration', async () => {
client.executeAdhocQuery.mockResolvedValue(queryResult([
{ catalog_name: 'kanabell-prod', schema_name: 'analytics', location: 'asia-northeast1' },
{ catalog_name: 'kanabell-prod', schema_name: 'raw', location: 'asia-northeast1' },
{ catalog_name: 'kanabell-prod', schema_name: 'staging', location: 'asia-northeast1' },
]));

const input = listBigQueryDatasetsSchema.parse({ dataSourceId: '4', pageSize: '2' });
const result = await service.listDatasets(input);

expect(client.executeAdhocQuery).toHaveBeenCalledWith(
expect.stringContaining('FROM `kanabell-prod`.`region-asia-northeast1`.INFORMATION_SCHEMA.SCHEMATA'),
4
);
const query = client.executeAdhocQuery.mock.calls[0][0] as string;
expect(query).toContain('LIMIT 3\nOFFSET 0');
expect(result).toEqual({
page: 1,
pageSize: 2,
hasMore: true,
nextPage: 2,
datasets: [
{ projectId: 'kanabell-prod', dataset: 'analytics', location: 'asia-northeast1' },
{ projectId: 'kanabell-prod', dataset: 'raw', location: 'asia-northeast1' },
],
});
});

it('lists tables for only the requested dataset and page', async () => {
client.executeAdhocQuery.mockResolvedValue(queryResult([
{
table_catalog: 'reporting-prod',
table_schema: 'analytics',
table_name: 'orders',
table_type: 'BASE TABLE',
creation_time: '2026-07-21T00:00:00Z',
},
]));

const input = listBigQueryTablesSchema.parse({
dataSourceId: 4,
projectId: 'reporting-prod',
dataset: 'analytics',
page: 2,
pageSize: 2,
});
const result = await service.listTables(input);

const query = client.executeAdhocQuery.mock.calls[0][0] as string;
expect(query).toContain('FROM `reporting-prod`.`analytics`.INFORMATION_SCHEMA.TABLES');
expect(query).toContain('LIMIT 3\nOFFSET 2');
expect(result.hasMore).toBe(false);
expect(result.nextPage).toBeNull();
expect(result.tables[0]).toMatchObject({ name: 'orders', type: 'BASE TABLE' });
});

it('accepts Redash query_result wrappers and defaults an empty configured location to US', async () => {
client.getDataSource.mockResolvedValue({
id: 4,
name: 'BigQuery',
type: 'bigquery',
options: { projectId: 'kanabell-prod', location: '' },
});
client.executeAdhocQuery.mockResolvedValue({
query_result: queryResult([
{ catalog_name: 'kanabell-prod', schema_name: 'analytics', location: 'US' },
]),
});

const result = await service.listDatasets(listBigQueryDatasetsSchema.parse({ dataSourceId: 4 }));

const query = client.executeAdhocQuery.mock.calls[0][0] as string;
expect(query).toContain('FROM `kanabell-prod`.`region-us`.INFORMATION_SCHEMA.SCHEMATA');
expect(result.datasets).toEqual([
{ projectId: 'kanabell-prod', dataset: 'analytics', location: 'US' },
]);
});

it('gets bounded column metadata for one table and escapes its name', async () => {
client.executeAdhocQuery.mockResolvedValue(queryResult([
{
column_name: 'order_id',
ordinal_position: 1,
data_type: 'STRING',
is_nullable: 'NO',
is_partitioning_column: 'YES',
clustering_ordinal_position: null,
},
]));

const input = getBigQueryTableSchemaSchema.parse({
dataSourceId: 4,
dataset: 'analytics',
table: "order's",
pageSize: 100,
});
const result = await service.getTableSchema(input);

const query = client.executeAdhocQuery.mock.calls[0][0] as string;
expect(query).toContain('FROM `kanabell-prod`.`analytics`.INFORMATION_SCHEMA.COLUMNS');
expect(query).toContain("WHERE table_name = 'order\\'s'");
expect(query).toContain('LIMIT 101\nOFFSET 0');
expect(result.columns).toEqual([
{
name: 'order_id',
position: 1,
type: 'STRING',
nullable: false,
isPartitioningColumn: true,
clusteringPosition: null,
},
]);
});

it('rejects BigQuery-specific discovery for a different data source type', async () => {
client.getDataSource.mockResolvedValue({ id: 2, name: 'MySQL', type: 'mysql' });
const input = listBigQueryTablesSchema.parse({
dataSourceId: 2,
dataset: 'analytics',
});

await expect(service.listTables(input)).rejects.toThrow('is not a BigQuery data source');
expect(client.executeAdhocQuery).not.toHaveBeenCalled();
});

it('rejects unsafe datasets and oversized pages before executing a query', () => {
expect(() => listBigQueryTablesSchema.parse({
dataSourceId: 4,
dataset: 'analytics` UNION ALL SELECT secret',
})).toThrow();
expect(() => listBigQueryDatasetsSchema.parse({
dataSourceId: 4,
pageSize: 101,
})).toThrow();
});
});
4 changes: 2 additions & 2 deletions src/__tests__/mcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ describe("Redash MCP server", () => {
jest.restoreAllMocks();
});

it("defines all 67 public tools", () => {
expect(toolDefinitions).toHaveLength(67);
it("defines all 70 public tools", () => {
expect(toolDefinitions).toHaveLength(70);
});

it("advertises the published package version", async () => {
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/redashClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,23 @@ describe('RedashClient', () => {
});
});

describe('getDataSource', () => {
it('should fetch one data source with its details', async () => {
const mockDataSource = {
id: 4,
name: 'BigQuery',
type: 'bigquery',
options: { projectId: 'kanabell-prod', location: 'asia-northeast1' },
};
mockAxiosInstance.get.mockResolvedValue({ data: mockDataSource });

const result = await client.getDataSource(4);

expect(mockAxiosInstance.get).toHaveBeenCalledWith('/api/data_sources/4');
expect(result).toEqual(mockDataSource);
});
});

describe('getDashboards', () => {
it('should fetch dashboards', async () => {
const mockResponse = {
Expand Down
Loading