@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
- this.service = new BigQueryService({ projectId, location, keyFilename }, this.logger);
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 { content: [{ type: 'text', text: result.error }], isError: true };
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 ? `${(Number(bytesProcessed) / 1e6).toFixed(2)} MB processed` : null,
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 { content: [{ type: 'text', text: `${body}\n\n${meta}` }] };
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 { content: [{ type: 'text', text: result.error }], isError: true };
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 { content: [{ type: 'text', text: result.error }], isError: true };
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 { content: [{ type: 'text', text: result.error }], isError: true };
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 { content: [{ type: 'text', text: result.error }], isError: true };
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) + `\n\n${rowCount} row${rowCount === 1 ? '' : 's'} previewed`;
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?.destinationTable
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) => ({ name, type: 'UNKNOWN', mode: 'NULLABLE' }));
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: rows.length,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imazhar101/mcp-bigquery-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "BigQuery MCP server — read-only query execution, schema exploration, dataset discovery",
5
5
  "type": "module",
6
6
  "main": "dist/servers/bigquery/src/index.js",