@imazhar101/mcp-bigquery-server 1.0.0 → 1.0.2
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.
|
@@ -3,8 +3,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { Logger } from '../../../shared/utils/logger.js';
|
|
6
|
-
import { getEnvVar, getLogLevel, getOptionalEnvVar } from '../../../shared/utils/config.js';
|
|
7
|
-
import { formatAsTable, formatAsJson } from '../../../shared/utils/formatter.js';
|
|
6
|
+
import { getEnvVar, getLogLevel, getOptionalEnvVar, } from '../../../shared/utils/config.js';
|
|
7
|
+
import { formatAsTable, formatAsJson, } from '../../../shared/utils/formatter.js';
|
|
8
8
|
import { BigQueryService } from './services/bigquery-service.js';
|
|
9
9
|
import { bigqueryTools } from './tools/index.js';
|
|
10
10
|
class BigQueryServer {
|
|
@@ -16,7 +16,14 @@ class BigQueryServer {
|
|
|
16
16
|
const projectId = getEnvVar('BIGQUERY_PROJECT_ID');
|
|
17
17
|
const location = getOptionalEnvVar('BIGQUERY_LOCATION', 'US');
|
|
18
18
|
const keyFilename = getOptionalEnvVar('BIGQUERY_KEY_FILE') || undefined;
|
|
19
|
-
|
|
19
|
+
const preventedFields = getOptionalEnvVar('BIGQUERY_PREVENTED_FIELDS')
|
|
20
|
+
.split(',')
|
|
21
|
+
.map((f) => f.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
if (preventedFields.length > 0) {
|
|
24
|
+
this.logger.info(`PII field filter active — blocking: ${preventedFields.join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
this.service = new BigQueryService({ projectId, location, keyFilename, preventedFields }, this.logger);
|
|
20
27
|
this.server = new Server({ name: 'bigquery-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
21
28
|
this.setupToolHandlers();
|
|
22
29
|
this.setupErrorHandling();
|
|
@@ -42,26 +49,39 @@ class BigQueryServer {
|
|
|
42
49
|
case 'query': {
|
|
43
50
|
const result = await this.service.executeQuery(args.sql, args.max_rows ?? 100, args.project_id);
|
|
44
51
|
if (!result.success) {
|
|
45
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: 'text', text: result.error }],
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
46
56
|
}
|
|
47
|
-
const { rows, rowCount, schema, bytesProcessed, cacheHit } = result.data;
|
|
57
|
+
const { rows, rowCount, schema, bytesProcessed, cacheHit, strippedFields, } = result.data;
|
|
48
58
|
const headers = schema?.map((f) => f.name) ?? Object.keys(rows[0] ?? {});
|
|
49
59
|
const body = args.format === 'json'
|
|
50
60
|
? formatAsJson(rows)
|
|
51
61
|
: formatAsTable(headers, rows);
|
|
52
62
|
const meta = [
|
|
53
63
|
`${rowCount} row${rowCount === 1 ? '' : 's'}`,
|
|
54
|
-
bytesProcessed
|
|
64
|
+
bytesProcessed
|
|
65
|
+
? `${(Number(bytesProcessed) / 1e6).toFixed(2)} MB processed`
|
|
66
|
+
: null,
|
|
55
67
|
cacheHit ? 'cache hit' : null,
|
|
68
|
+
strippedFields?.length
|
|
69
|
+
? `⚠️ ${strippedFields.length} PII field(s) redacted: ${strippedFields.join(', ')}`
|
|
70
|
+
: null,
|
|
56
71
|
]
|
|
57
72
|
.filter(Boolean)
|
|
58
73
|
.join(' · ');
|
|
59
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: `${body}\n\n${meta}` }],
|
|
76
|
+
};
|
|
60
77
|
}
|
|
61
78
|
case 'list_datasets': {
|
|
62
79
|
const result = await this.service.listDatasets(args.project_id);
|
|
63
80
|
if (!result.success) {
|
|
64
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: 'text', text: result.error }],
|
|
83
|
+
isError: true,
|
|
84
|
+
};
|
|
65
85
|
}
|
|
66
86
|
const rows = result.data.map((d) => ({
|
|
67
87
|
dataset: d.id,
|
|
@@ -71,14 +91,16 @@ class BigQueryServer {
|
|
|
71
91
|
}));
|
|
72
92
|
const text = rows.length === 0
|
|
73
93
|
? 'No datasets found.'
|
|
74
|
-
: formatAsTable(['dataset', 'location', 'created', 'description'], rows) +
|
|
75
|
-
`\n\n${rows.length} dataset${rows.length === 1 ? '' : 's'}`;
|
|
94
|
+
: formatAsTable(['dataset', 'location', 'created', 'description'], rows) + `\n\n${rows.length} dataset${rows.length === 1 ? '' : 's'}`;
|
|
76
95
|
return { content: [{ type: 'text', text }] };
|
|
77
96
|
}
|
|
78
97
|
case 'list_tables': {
|
|
79
98
|
const result = await this.service.listTables(args.dataset, args.project_id);
|
|
80
99
|
if (!result.success) {
|
|
81
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: 'text', text: result.error }],
|
|
102
|
+
isError: true,
|
|
103
|
+
};
|
|
82
104
|
}
|
|
83
105
|
const rows = result.data.map((t) => ({
|
|
84
106
|
table: t.id,
|
|
@@ -98,7 +120,10 @@ class BigQueryServer {
|
|
|
98
120
|
case 'get_table_schema': {
|
|
99
121
|
const result = await this.service.getTableSchema(args.dataset, args.table, args.project_id);
|
|
100
122
|
if (!result.success) {
|
|
101
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: 'text', text: result.error }],
|
|
125
|
+
isError: true,
|
|
126
|
+
};
|
|
102
127
|
}
|
|
103
128
|
const rows = result.data.map((f) => ({
|
|
104
129
|
column: f.name,
|
|
@@ -115,13 +140,20 @@ class BigQueryServer {
|
|
|
115
140
|
case 'preview_table': {
|
|
116
141
|
const result = await this.service.previewTable(args.dataset, args.table, args.limit ?? 10, args.project_id);
|
|
117
142
|
if (!result.success) {
|
|
118
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: 'text', text: result.error }],
|
|
145
|
+
isError: true,
|
|
146
|
+
};
|
|
119
147
|
}
|
|
120
|
-
const { rows, rowCount, schema } = result.data;
|
|
148
|
+
const { rows, rowCount, schema, strippedFields } = result.data;
|
|
121
149
|
const headers = schema?.map((f) => f.name) ?? Object.keys(rows[0] ?? {});
|
|
150
|
+
const notice = strippedFields?.length
|
|
151
|
+
? `\n⚠️ ${strippedFields.length} PII field(s) redacted: ${strippedFields.join(', ')}`
|
|
152
|
+
: '';
|
|
122
153
|
const text = rowCount === 0
|
|
123
154
|
? `Table "${args.dataset}.${args.table}" is empty.`
|
|
124
|
-
: formatAsTable(headers, rows) +
|
|
155
|
+
: formatAsTable(headers, rows) +
|
|
156
|
+
`\n\n${rowCount} row${rowCount === 1 ? '' : 's'} previewed${notice}`;
|
|
125
157
|
return { content: [{ type: 'text', text }] };
|
|
126
158
|
}
|
|
127
159
|
default:
|
|
@@ -26,7 +26,6 @@ export class BigQueryService {
|
|
|
26
26
|
query: sql,
|
|
27
27
|
dryRun: true,
|
|
28
28
|
location: this.config.location,
|
|
29
|
-
defaultDataset: { projectId: billingProject },
|
|
30
29
|
});
|
|
31
30
|
const statementType = dryRunJob.metadata?.statistics?.query?.statementType ?? '';
|
|
32
31
|
if (statementType !== 'SELECT') {
|
|
@@ -41,14 +40,15 @@ export class BigQueryService {
|
|
|
41
40
|
const [job] = await this.client.createQueryJob({
|
|
42
41
|
query: sql,
|
|
43
42
|
location: this.config.location,
|
|
44
|
-
defaultDataset: { projectId: billingProject },
|
|
45
43
|
maximumBytesBilled: String(10 * 1024 * 1024 * 1024), // 10 GB safety cap
|
|
46
44
|
});
|
|
47
45
|
const [rows] = await job.getQueryResults({ maxResults: clampedRows });
|
|
48
46
|
const jobMeta = job.metadata?.statistics?.query;
|
|
49
|
-
const schemaFields = job.metadata?.configuration?.query
|
|
47
|
+
const schemaFields = job.metadata?.configuration?.query
|
|
48
|
+
?.destinationTable
|
|
50
49
|
? []
|
|
51
|
-
: ((await job.getMetadata())[0]?.statistics?.query?.schema?.fields ??
|
|
50
|
+
: ((await job.getMetadata())[0]?.statistics?.query?.schema?.fields ??
|
|
51
|
+
[]);
|
|
52
52
|
// Schema is more reliably fetched from the query response rows keys
|
|
53
53
|
const headers = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
54
54
|
const schema = schemaFields.length > 0
|
|
@@ -58,15 +58,24 @@ export class BigQueryService {
|
|
|
58
58
|
mode: f.mode ?? 'NULLABLE',
|
|
59
59
|
description: f.description,
|
|
60
60
|
}))
|
|
61
|
-
: headers.map((name) => ({
|
|
61
|
+
: headers.map((name) => ({
|
|
62
|
+
name,
|
|
63
|
+
type: 'UNKNOWN',
|
|
64
|
+
mode: 'NULLABLE',
|
|
65
|
+
}));
|
|
66
|
+
const { filteredRows, filteredSchema, strippedFields } = this.applyFieldFilter(rows, schema);
|
|
67
|
+
if (strippedFields.length > 0) {
|
|
68
|
+
this.logger.warn(`Stripped ${strippedFields.length} prevented field(s) from results: ${strippedFields.join(', ')}`);
|
|
69
|
+
}
|
|
62
70
|
return {
|
|
63
71
|
success: true,
|
|
64
72
|
data: {
|
|
65
|
-
rows,
|
|
66
|
-
rowCount:
|
|
67
|
-
schema,
|
|
73
|
+
rows: filteredRows,
|
|
74
|
+
rowCount: filteredRows.length,
|
|
75
|
+
schema: filteredSchema,
|
|
68
76
|
bytesProcessed,
|
|
69
77
|
cacheHit: jobMeta?.cacheHit ?? false,
|
|
78
|
+
strippedFields: strippedFields.length > 0 ? strippedFields : undefined,
|
|
70
79
|
},
|
|
71
80
|
};
|
|
72
81
|
}
|
|
@@ -78,6 +87,34 @@ export class BigQueryService {
|
|
|
78
87
|
};
|
|
79
88
|
}
|
|
80
89
|
}
|
|
90
|
+
applyFieldFilter(rows, schema) {
|
|
91
|
+
const prevented = this.config.preventedFields ?? [];
|
|
92
|
+
if (prevented.length === 0) {
|
|
93
|
+
return { filteredRows: rows, filteredSchema: schema, strippedFields: [] };
|
|
94
|
+
}
|
|
95
|
+
// Case-insensitive match against full field name or glob-style suffix (*_email, email_*)
|
|
96
|
+
const isBlocked = (name) => prevented.some((p) => name.toLowerCase() === p.toLowerCase());
|
|
97
|
+
const allFieldNames = schema.length > 0
|
|
98
|
+
? schema.map((f) => f.name)
|
|
99
|
+
: rows.length > 0
|
|
100
|
+
? Object.keys(rows[0])
|
|
101
|
+
: [];
|
|
102
|
+
const strippedFields = allFieldNames.filter(isBlocked);
|
|
103
|
+
if (strippedFields.length === 0) {
|
|
104
|
+
return { filteredRows: rows, filteredSchema: schema, strippedFields: [] };
|
|
105
|
+
}
|
|
106
|
+
const strippedSet = new Set(strippedFields.map((f) => f.toLowerCase()));
|
|
107
|
+
const filteredSchema = schema.filter((f) => !strippedSet.has(f.name.toLowerCase()));
|
|
108
|
+
const filteredRows = rows.map((row) => {
|
|
109
|
+
const clean = {};
|
|
110
|
+
for (const [k, v] of Object.entries(row)) {
|
|
111
|
+
if (!strippedSet.has(k.toLowerCase()))
|
|
112
|
+
clean[k] = v;
|
|
113
|
+
}
|
|
114
|
+
return clean;
|
|
115
|
+
});
|
|
116
|
+
return { filteredRows, filteredSchema, strippedFields };
|
|
117
|
+
}
|
|
81
118
|
async listDatasets(projectId) {
|
|
82
119
|
const project = projectId ?? this.config.projectId;
|
|
83
120
|
this.logger.info(`Listing datasets in project: ${project}`);
|
package/package.json
CHANGED