@imazhar101/mcp-bigquery-server 1.0.0
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.
- package/dist/servers/bigquery/src/index.js +153 -0
- package/dist/servers/bigquery/src/services/bigquery-service.js +148 -0
- package/dist/servers/bigquery/src/tools/index.js +105 -0
- package/dist/servers/bigquery/src/types/index.js +1 -0
- package/dist/shared/middleware/auth.js +40 -0
- package/dist/shared/middleware/error-handler.js +44 -0
- package/dist/shared/middleware/request-logger.js +14 -0
- package/dist/shared/types/common.js +1 -0
- package/dist/shared/types/mcp.js +1 -0
- package/dist/shared/utils/config.js +23 -0
- package/dist/shared/utils/formatter.js +47 -0
- package/dist/shared/utils/logger.js +46 -0
- package/dist/shared/utils/validation.js +24 -0
- package/package.json +25 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
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';
|
|
8
|
+
import { BigQueryService } from './services/bigquery-service.js';
|
|
9
|
+
import { bigqueryTools } from './tools/index.js';
|
|
10
|
+
class BigQueryServer {
|
|
11
|
+
server;
|
|
12
|
+
service;
|
|
13
|
+
logger;
|
|
14
|
+
constructor() {
|
|
15
|
+
this.logger = new Logger(getLogLevel(), { server: 'bigquery' });
|
|
16
|
+
const projectId = getEnvVar('BIGQUERY_PROJECT_ID');
|
|
17
|
+
const location = getOptionalEnvVar('BIGQUERY_LOCATION', 'US');
|
|
18
|
+
const keyFilename = getOptionalEnvVar('BIGQUERY_KEY_FILE') || undefined;
|
|
19
|
+
this.service = new BigQueryService({ projectId, location, keyFilename }, this.logger);
|
|
20
|
+
this.server = new Server({ name: 'bigquery-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
21
|
+
this.setupToolHandlers();
|
|
22
|
+
this.setupErrorHandling();
|
|
23
|
+
}
|
|
24
|
+
setupToolHandlers() {
|
|
25
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
26
|
+
tools: bigqueryTools,
|
|
27
|
+
}));
|
|
28
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
29
|
+
try {
|
|
30
|
+
return await this.handleToolCall(request.params.name, request.params.arguments);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
this.logger.error('Tool call failed', error);
|
|
34
|
+
if (error instanceof McpError)
|
|
35
|
+
throw error;
|
|
36
|
+
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error}`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async handleToolCall(toolName, args) {
|
|
41
|
+
switch (toolName) {
|
|
42
|
+
case 'query': {
|
|
43
|
+
const result = await this.service.executeQuery(args.sql, args.max_rows ?? 100, args.project_id);
|
|
44
|
+
if (!result.success) {
|
|
45
|
+
return { content: [{ type: 'text', text: result.error }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
const { rows, rowCount, schema, bytesProcessed, cacheHit } = result.data;
|
|
48
|
+
const headers = schema?.map((f) => f.name) ?? Object.keys(rows[0] ?? {});
|
|
49
|
+
const body = args.format === 'json'
|
|
50
|
+
? formatAsJson(rows)
|
|
51
|
+
: formatAsTable(headers, rows);
|
|
52
|
+
const meta = [
|
|
53
|
+
`${rowCount} row${rowCount === 1 ? '' : 's'}`,
|
|
54
|
+
bytesProcessed ? `${(Number(bytesProcessed) / 1e6).toFixed(2)} MB processed` : null,
|
|
55
|
+
cacheHit ? 'cache hit' : null,
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(' · ');
|
|
59
|
+
return { content: [{ type: 'text', text: `${body}\n\n${meta}` }] };
|
|
60
|
+
}
|
|
61
|
+
case 'list_datasets': {
|
|
62
|
+
const result = await this.service.listDatasets(args.project_id);
|
|
63
|
+
if (!result.success) {
|
|
64
|
+
return { content: [{ type: 'text', text: result.error }], isError: true };
|
|
65
|
+
}
|
|
66
|
+
const rows = result.data.map((d) => ({
|
|
67
|
+
dataset: d.id,
|
|
68
|
+
location: d.location,
|
|
69
|
+
created: d.createdAt?.split('T')[0] ?? '',
|
|
70
|
+
description: d.description ?? '',
|
|
71
|
+
}));
|
|
72
|
+
const text = rows.length === 0
|
|
73
|
+
? 'No datasets found.'
|
|
74
|
+
: formatAsTable(['dataset', 'location', 'created', 'description'], rows) +
|
|
75
|
+
`\n\n${rows.length} dataset${rows.length === 1 ? '' : 's'}`;
|
|
76
|
+
return { content: [{ type: 'text', text }] };
|
|
77
|
+
}
|
|
78
|
+
case 'list_tables': {
|
|
79
|
+
const result = await this.service.listTables(args.dataset, args.project_id);
|
|
80
|
+
if (!result.success) {
|
|
81
|
+
return { content: [{ type: 'text', text: result.error }], isError: true };
|
|
82
|
+
}
|
|
83
|
+
const rows = result.data.map((t) => ({
|
|
84
|
+
table: t.id,
|
|
85
|
+
type: t.type,
|
|
86
|
+
rows: t.rowCount ? Number(t.rowCount).toLocaleString() : '—',
|
|
87
|
+
size: t.sizeBytes
|
|
88
|
+
? `${(Number(t.sizeBytes) / 1e6).toFixed(2)} MB`
|
|
89
|
+
: '—',
|
|
90
|
+
created: t.createdAt?.split('T')[0] ?? '',
|
|
91
|
+
}));
|
|
92
|
+
const text = rows.length === 0
|
|
93
|
+
? `No tables found in dataset "${args.dataset}".`
|
|
94
|
+
: formatAsTable(['table', 'type', 'rows', 'size', 'created'], rows) +
|
|
95
|
+
`\n\n${rows.length} table${rows.length === 1 ? '' : 's'} in ${args.dataset}`;
|
|
96
|
+
return { content: [{ type: 'text', text }] };
|
|
97
|
+
}
|
|
98
|
+
case 'get_table_schema': {
|
|
99
|
+
const result = await this.service.getTableSchema(args.dataset, args.table, args.project_id);
|
|
100
|
+
if (!result.success) {
|
|
101
|
+
return { content: [{ type: 'text', text: result.error }], isError: true };
|
|
102
|
+
}
|
|
103
|
+
const rows = result.data.map((f) => ({
|
|
104
|
+
column: f.name,
|
|
105
|
+
type: f.type,
|
|
106
|
+
mode: f.mode,
|
|
107
|
+
description: f.description ?? '',
|
|
108
|
+
}));
|
|
109
|
+
const text = rows.length === 0
|
|
110
|
+
? 'Table has no schema fields.'
|
|
111
|
+
: formatAsTable(['column', 'type', 'mode', 'description'], rows) +
|
|
112
|
+
`\n\n${rows.length} column${rows.length === 1 ? '' : 's'} — ${args.dataset}.${args.table}`;
|
|
113
|
+
return { content: [{ type: 'text', text }] };
|
|
114
|
+
}
|
|
115
|
+
case 'preview_table': {
|
|
116
|
+
const result = await this.service.previewTable(args.dataset, args.table, args.limit ?? 10, args.project_id);
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
return { content: [{ type: 'text', text: result.error }], isError: true };
|
|
119
|
+
}
|
|
120
|
+
const { rows, rowCount, schema } = result.data;
|
|
121
|
+
const headers = schema?.map((f) => f.name) ?? Object.keys(rows[0] ?? {});
|
|
122
|
+
const text = rowCount === 0
|
|
123
|
+
? `Table "${args.dataset}.${args.table}" is empty.`
|
|
124
|
+
: formatAsTable(headers, rows) + `\n\n${rowCount} row${rowCount === 1 ? '' : 's'} previewed`;
|
|
125
|
+
return { content: [{ type: 'text', text }] };
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
setupErrorHandling() {
|
|
132
|
+
this.server.onerror = (error) => {
|
|
133
|
+
this.logger.error('MCP Server error', error);
|
|
134
|
+
};
|
|
135
|
+
const shutdown = async () => {
|
|
136
|
+
this.logger.info('Shutting down BigQuery MCP server');
|
|
137
|
+
await this.server.close();
|
|
138
|
+
process.exit(0);
|
|
139
|
+
};
|
|
140
|
+
process.on('SIGINT', shutdown);
|
|
141
|
+
process.on('SIGTERM', shutdown);
|
|
142
|
+
}
|
|
143
|
+
async run() {
|
|
144
|
+
const transport = new StdioServerTransport();
|
|
145
|
+
await this.server.connect(transport);
|
|
146
|
+
this.logger.info('BigQuery MCP server running on stdio');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const server = new BigQueryServer();
|
|
150
|
+
server.run().catch((error) => {
|
|
151
|
+
console.error('Failed to start BigQuery MCP server:', error);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { BigQuery } from '@google-cloud/bigquery';
|
|
2
|
+
const MAX_ROWS_HARD_LIMIT = 1000;
|
|
3
|
+
export class BigQueryService {
|
|
4
|
+
client;
|
|
5
|
+
config;
|
|
6
|
+
logger;
|
|
7
|
+
constructor(config, logger) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
const options = {
|
|
11
|
+
projectId: config.projectId,
|
|
12
|
+
location: config.location,
|
|
13
|
+
};
|
|
14
|
+
if (config.keyFilename) {
|
|
15
|
+
options.keyFilename = config.keyFilename;
|
|
16
|
+
}
|
|
17
|
+
this.client = new BigQuery(options);
|
|
18
|
+
}
|
|
19
|
+
async executeQuery(sql, maxRows = 100, projectId) {
|
|
20
|
+
const billingProject = projectId ?? this.config.projectId;
|
|
21
|
+
const clampedRows = Math.min(maxRows, MAX_ROWS_HARD_LIMIT);
|
|
22
|
+
this.logger.info(`Dry-run validating query on project: ${billingProject}`);
|
|
23
|
+
try {
|
|
24
|
+
// Enforce read-only via BigQuery dry-run — checks statementType server-side
|
|
25
|
+
const [dryRunJob] = await this.client.createQueryJob({
|
|
26
|
+
query: sql,
|
|
27
|
+
dryRun: true,
|
|
28
|
+
location: this.config.location,
|
|
29
|
+
defaultDataset: { projectId: billingProject },
|
|
30
|
+
});
|
|
31
|
+
const statementType = dryRunJob.metadata?.statistics?.query?.statementType ?? '';
|
|
32
|
+
if (statementType !== 'SELECT') {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
error: `Only SELECT queries are allowed. Detected statement type: "${statementType}".`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const bytesProcessed = dryRunJob.metadata?.statistics?.totalBytesProcessed ?? '0';
|
|
39
|
+
this.logger.info(`Dry-run OK — type: ${statementType}, bytes: ${bytesProcessed}`);
|
|
40
|
+
// Execute for real — use createQueryJob to access job metadata (schema, cacheHit)
|
|
41
|
+
const [job] = await this.client.createQueryJob({
|
|
42
|
+
query: sql,
|
|
43
|
+
location: this.config.location,
|
|
44
|
+
defaultDataset: { projectId: billingProject },
|
|
45
|
+
maximumBytesBilled: String(10 * 1024 * 1024 * 1024), // 10 GB safety cap
|
|
46
|
+
});
|
|
47
|
+
const [rows] = await job.getQueryResults({ maxResults: clampedRows });
|
|
48
|
+
const jobMeta = job.metadata?.statistics?.query;
|
|
49
|
+
const schemaFields = job.metadata?.configuration?.query?.destinationTable
|
|
50
|
+
? []
|
|
51
|
+
: ((await job.getMetadata())[0]?.statistics?.query?.schema?.fields ?? []);
|
|
52
|
+
// Schema is more reliably fetched from the query response rows keys
|
|
53
|
+
const headers = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
54
|
+
const schema = schemaFields.length > 0
|
|
55
|
+
? schemaFields.map((f) => ({
|
|
56
|
+
name: f.name,
|
|
57
|
+
type: f.type,
|
|
58
|
+
mode: f.mode ?? 'NULLABLE',
|
|
59
|
+
description: f.description,
|
|
60
|
+
}))
|
|
61
|
+
: headers.map((name) => ({ name, type: 'UNKNOWN', mode: 'NULLABLE' }));
|
|
62
|
+
return {
|
|
63
|
+
success: true,
|
|
64
|
+
data: {
|
|
65
|
+
rows,
|
|
66
|
+
rowCount: rows.length,
|
|
67
|
+
schema,
|
|
68
|
+
bytesProcessed,
|
|
69
|
+
cacheHit: jobMeta?.cacheHit ?? false,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
this.logger.error('Query failed', err);
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: err?.message ?? String(err),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async listDatasets(projectId) {
|
|
82
|
+
const project = projectId ?? this.config.projectId;
|
|
83
|
+
this.logger.info(`Listing datasets in project: ${project}`);
|
|
84
|
+
try {
|
|
85
|
+
const [datasets] = await this.client.getDatasets({ projectId: project });
|
|
86
|
+
const data = datasets.map((ds) => ({
|
|
87
|
+
id: ds.id ?? '',
|
|
88
|
+
location: ds.metadata?.location ?? '',
|
|
89
|
+
createdAt: ds.metadata?.creationTime
|
|
90
|
+
? new Date(Number(ds.metadata.creationTime)).toISOString()
|
|
91
|
+
: undefined,
|
|
92
|
+
description: ds.metadata?.description,
|
|
93
|
+
}));
|
|
94
|
+
return { success: true, data };
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
return { success: false, error: err?.message ?? String(err) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async listTables(datasetId, projectId) {
|
|
101
|
+
const project = projectId ?? this.config.projectId;
|
|
102
|
+
this.logger.info(`Listing tables in ${project}.${datasetId}`);
|
|
103
|
+
try {
|
|
104
|
+
const dataset = this.client.dataset(datasetId, { projectId: project });
|
|
105
|
+
const [tables] = await dataset.getTables();
|
|
106
|
+
const data = tables.map((t) => ({
|
|
107
|
+
id: t.id ?? '',
|
|
108
|
+
type: t.metadata?.type ?? 'TABLE',
|
|
109
|
+
rowCount: t.metadata?.numRows,
|
|
110
|
+
sizeBytes: t.metadata?.numBytes,
|
|
111
|
+
createdAt: t.metadata?.creationTime
|
|
112
|
+
? new Date(Number(t.metadata.creationTime)).toISOString()
|
|
113
|
+
: undefined,
|
|
114
|
+
description: t.metadata?.description,
|
|
115
|
+
}));
|
|
116
|
+
return { success: true, data };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return { success: false, error: err?.message ?? String(err) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async getTableSchema(datasetId, tableId, projectId) {
|
|
123
|
+
const project = projectId ?? this.config.projectId;
|
|
124
|
+
this.logger.info(`Getting schema for ${project}.${datasetId}.${tableId}`);
|
|
125
|
+
try {
|
|
126
|
+
const table = this.client
|
|
127
|
+
.dataset(datasetId, { projectId: project })
|
|
128
|
+
.table(tableId);
|
|
129
|
+
const [metadata] = await table.getMetadata();
|
|
130
|
+
const fields = (metadata?.schema?.fields ?? []).map((f) => ({
|
|
131
|
+
name: f.name,
|
|
132
|
+
type: f.type,
|
|
133
|
+
mode: f.mode ?? 'NULLABLE',
|
|
134
|
+
description: f.description,
|
|
135
|
+
}));
|
|
136
|
+
return { success: true, data: fields };
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return { success: false, error: err?.message ?? String(err) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async previewTable(datasetId, tableId, limit = 10, projectId) {
|
|
143
|
+
const project = projectId ?? this.config.projectId;
|
|
144
|
+
const clampedLimit = Math.min(limit, 100);
|
|
145
|
+
const sql = `SELECT * FROM \`${project}.${datasetId}.${tableId}\` LIMIT ${clampedLimit}`;
|
|
146
|
+
return this.executeQuery(sql, clampedLimit, project);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export const bigqueryTools = [
|
|
2
|
+
{
|
|
3
|
+
name: 'query',
|
|
4
|
+
description: 'Execute a read-only BigQuery SQL query. Only SELECT statements are allowed — enforced via BigQuery dry-run before execution. Results are returned as a formatted table. Use backtick-quoted fully qualified table names: `project.dataset.table`.',
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
sql: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
description: 'The SQL SELECT query to execute',
|
|
11
|
+
},
|
|
12
|
+
max_rows: {
|
|
13
|
+
type: 'number',
|
|
14
|
+
description: 'Maximum rows to return (default: 100, max: 1000)',
|
|
15
|
+
},
|
|
16
|
+
format: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
enum: ['table', 'json'],
|
|
19
|
+
description: 'Output format — "table" (default) or "json"',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
required: ['sql'],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'list_datasets',
|
|
27
|
+
description: 'List all datasets in the configured BigQuery project.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
project_id: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Override project ID (defaults to configured project)',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: [],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'list_tables',
|
|
41
|
+
description: 'List all tables and views in a BigQuery dataset.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
dataset: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Dataset ID to list tables from',
|
|
48
|
+
},
|
|
49
|
+
project_id: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Override project ID (defaults to configured project)',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
required: ['dataset'],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'get_table_schema',
|
|
59
|
+
description: 'Get the full schema (columns, types, modes) for a BigQuery table or view.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
dataset: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'Dataset ID containing the table',
|
|
66
|
+
},
|
|
67
|
+
table: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'Table or view name',
|
|
70
|
+
},
|
|
71
|
+
project_id: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description: 'Override project ID (defaults to configured project)',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
required: ['dataset', 'table'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'preview_table',
|
|
81
|
+
description: 'Preview the first N rows of a BigQuery table without writing SQL.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
dataset: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
description: 'Dataset ID containing the table',
|
|
88
|
+
},
|
|
89
|
+
table: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'Table name to preview',
|
|
92
|
+
},
|
|
93
|
+
limit: {
|
|
94
|
+
type: 'number',
|
|
95
|
+
description: 'Number of rows to preview (default: 10, max: 100)',
|
|
96
|
+
},
|
|
97
|
+
project_id: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Override project ID (defaults to configured project)',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ['dataset', 'table'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { validateRequired, validateApiKey } from '../utils/validation.js';
|
|
2
|
+
export class AuthMiddleware {
|
|
3
|
+
credentials;
|
|
4
|
+
constructor(credentials) {
|
|
5
|
+
this.credentials = credentials;
|
|
6
|
+
this.validateCredentials();
|
|
7
|
+
}
|
|
8
|
+
validateCredentials() {
|
|
9
|
+
if (this.credentials.apiKey) {
|
|
10
|
+
if (!validateApiKey(this.credentials.apiKey)) {
|
|
11
|
+
throw new Error('Invalid API key format');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (this.credentials.username) {
|
|
15
|
+
validateRequired(this.credentials.username, 'Username');
|
|
16
|
+
}
|
|
17
|
+
if (this.credentials.baseUrl) {
|
|
18
|
+
validateRequired(this.credentials.baseUrl, 'Base URL');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
getAuthHeaders() {
|
|
22
|
+
const headers = {};
|
|
23
|
+
if (this.credentials.apiKey) {
|
|
24
|
+
headers['Authorization'] = `Bearer ${this.credentials.apiKey}`;
|
|
25
|
+
}
|
|
26
|
+
if (this.credentials.token) {
|
|
27
|
+
headers['Authorization'] = `Bearer ${this.credentials.token}`;
|
|
28
|
+
}
|
|
29
|
+
if (this.credentials.username && this.credentials.password) {
|
|
30
|
+
const auth = Buffer.from(`${this.credentials.username}:${this.credentials.password}`).toString('base64');
|
|
31
|
+
headers['Authorization'] = `Basic ${auth}`;
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
isAuthenticated() {
|
|
36
|
+
return !!(this.credentials.apiKey ||
|
|
37
|
+
this.credentials.token ||
|
|
38
|
+
(this.credentials.username && this.credentials.password));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export class ErrorHandler {
|
|
2
|
+
logger;
|
|
3
|
+
constructor(logger) {
|
|
4
|
+
this.logger = logger;
|
|
5
|
+
}
|
|
6
|
+
handleError(error, context) {
|
|
7
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
8
|
+
const contextMsg = context ? `[${context}]` : '';
|
|
9
|
+
this.logger.error(`${contextMsg} ${errorMessage}`, error);
|
|
10
|
+
return {
|
|
11
|
+
success: false,
|
|
12
|
+
error: errorMessage,
|
|
13
|
+
message: 'An error occurred while processing your request',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
handleApiError(error, apiName) {
|
|
17
|
+
if (error.response) {
|
|
18
|
+
const status = error.response.status;
|
|
19
|
+
const statusText = error.response.statusText;
|
|
20
|
+
const message = error.response.data?.message ||
|
|
21
|
+
error.response.data?.error ||
|
|
22
|
+
statusText;
|
|
23
|
+
this.logger.error(`${apiName} API error: ${status} ${statusText}`, {
|
|
24
|
+
status,
|
|
25
|
+
statusText,
|
|
26
|
+
data: error.response.data,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
success: false,
|
|
30
|
+
error: `${apiName} API error: ${message}`,
|
|
31
|
+
message: `Failed to communicate with ${apiName}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return this.handleError(error, `${apiName} API`);
|
|
35
|
+
}
|
|
36
|
+
handleValidationError(field, message) {
|
|
37
|
+
this.logger.warn(`Validation error for field '${field}': ${message}`);
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
error: `Validation failed for ${field}: ${message}`,
|
|
41
|
+
message: 'Please check your input and try again',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function logToolCall(logger, toolName, args, fn) {
|
|
2
|
+
const start = Date.now();
|
|
3
|
+
const argKeys = args && typeof args === 'object' ? Object.keys(args) : [];
|
|
4
|
+
logger.debug(`→ ${toolName}`, { args: argKeys });
|
|
5
|
+
return fn().then((result) => {
|
|
6
|
+
const ms = Date.now() - start;
|
|
7
|
+
logger.info(`← ${toolName}`, { ms, ok: true });
|
|
8
|
+
return result;
|
|
9
|
+
}, (error) => {
|
|
10
|
+
const ms = Date.now() - start;
|
|
11
|
+
logger.error(`✗ ${toolName} (${ms}ms)`, error instanceof Error ? error.message : String(error));
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function getEnvVar(key, defaultValue) {
|
|
2
|
+
const value = process.env[key];
|
|
3
|
+
if (value === undefined) {
|
|
4
|
+
if (defaultValue !== undefined) {
|
|
5
|
+
return defaultValue;
|
|
6
|
+
}
|
|
7
|
+
throw new Error(`Environment variable ${key} is required but not set`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function getOptionalEnvVar(key, defaultValue = '') {
|
|
12
|
+
return process.env[key] || defaultValue;
|
|
13
|
+
}
|
|
14
|
+
export function getLogLevel() {
|
|
15
|
+
const level = process.env.LOG_LEVEL?.toLowerCase();
|
|
16
|
+
return ['debug', 'info', 'warn', 'error'].includes(level) ? level : 'info';
|
|
17
|
+
}
|
|
18
|
+
export function isProduction() {
|
|
19
|
+
return process.env.NODE_ENV === 'production';
|
|
20
|
+
}
|
|
21
|
+
export function isDevelopment() {
|
|
22
|
+
return process.env.NODE_ENV === 'development';
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function formatAsTable(headers, rows) {
|
|
2
|
+
if (!headers || headers.length === 0)
|
|
3
|
+
return '(no columns)';
|
|
4
|
+
const cellValue = (val) => {
|
|
5
|
+
if (val === null || val === undefined)
|
|
6
|
+
return '';
|
|
7
|
+
if (typeof val === 'object')
|
|
8
|
+
return JSON.stringify(val);
|
|
9
|
+
return String(val);
|
|
10
|
+
};
|
|
11
|
+
// Support both array rows and object rows (keyed by header name)
|
|
12
|
+
const normalizedRows = rows.map((row) => {
|
|
13
|
+
if (Array.isArray(row))
|
|
14
|
+
return row.map(cellValue);
|
|
15
|
+
return headers.map((h) => cellValue(row[h]));
|
|
16
|
+
});
|
|
17
|
+
const headerRow = '|' + headers.join('|');
|
|
18
|
+
const dataRows = normalizedRows.map((row) => '|' + row.join('|'));
|
|
19
|
+
return [headerRow, ...dataRows].join('\n');
|
|
20
|
+
}
|
|
21
|
+
export function formatAsJson(data) {
|
|
22
|
+
return JSON.stringify(data, null, 2);
|
|
23
|
+
}
|
|
24
|
+
export function formatAsCsv(headers, rows) {
|
|
25
|
+
const escape = (val) => {
|
|
26
|
+
if (val === null || val === undefined)
|
|
27
|
+
return '';
|
|
28
|
+
const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
29
|
+
// Quote fields containing commas, quotes, or newlines
|
|
30
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
31
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
32
|
+
}
|
|
33
|
+
return str;
|
|
34
|
+
};
|
|
35
|
+
const normalizedRows = rows.map((row) => {
|
|
36
|
+
if (Array.isArray(row))
|
|
37
|
+
return row.map(escape);
|
|
38
|
+
return headers.map((h) => escape(row[h]));
|
|
39
|
+
});
|
|
40
|
+
return [
|
|
41
|
+
headers.map(escape).join(','),
|
|
42
|
+
...normalizedRows.map((row) => row.join(',')),
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
export function formatWriteResult(rowCount) {
|
|
46
|
+
return `${rowCount} row${rowCount === 1 ? '' : 's'} affected`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class Logger {
|
|
2
|
+
level;
|
|
3
|
+
context;
|
|
4
|
+
constructor(level = 'info', context = {}) {
|
|
5
|
+
this.level = level;
|
|
6
|
+
this.context = context;
|
|
7
|
+
}
|
|
8
|
+
shouldLog(level) {
|
|
9
|
+
const levels = {
|
|
10
|
+
debug: 0,
|
|
11
|
+
info: 1,
|
|
12
|
+
warn: 2,
|
|
13
|
+
error: 3,
|
|
14
|
+
};
|
|
15
|
+
return levels[level] >= levels[this.level];
|
|
16
|
+
}
|
|
17
|
+
formatMessage(level, message, data) {
|
|
18
|
+
const timestamp = new Date().toISOString();
|
|
19
|
+
const contextStr = this.context.server ? `[${this.context.server}]` : '';
|
|
20
|
+
const dataStr = data ? ` ${JSON.stringify(data)}` : '';
|
|
21
|
+
return `${timestamp} ${level.toUpperCase()} ${contextStr} ${message}${dataStr}`;
|
|
22
|
+
}
|
|
23
|
+
debug(message, data) {
|
|
24
|
+
if (this.shouldLog('debug')) {
|
|
25
|
+
console.error(this.formatMessage('debug', message, data));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
info(message, data) {
|
|
29
|
+
if (this.shouldLog('info')) {
|
|
30
|
+
console.error(this.formatMessage('info', message, data));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
warn(message, data) {
|
|
34
|
+
if (this.shouldLog('warn')) {
|
|
35
|
+
console.error(this.formatMessage('warn', message, data));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
error(message, error) {
|
|
39
|
+
if (this.shouldLog('error')) {
|
|
40
|
+
console.error(this.formatMessage('error', message, error));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
withContext(context) {
|
|
44
|
+
return new Logger(this.level, { ...this.context, ...context });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function validateRequired(value, fieldName) {
|
|
2
|
+
if (value === undefined || value === null || value === '') {
|
|
3
|
+
throw new Error(`${fieldName} is required`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
export function validateEmail(email) {
|
|
7
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
8
|
+
return emailRegex.test(email);
|
|
9
|
+
}
|
|
10
|
+
export function validateUrl(url) {
|
|
11
|
+
try {
|
|
12
|
+
new URL(url);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function sanitizeString(input) {
|
|
20
|
+
return input.trim().replace(/[<>]/g, '');
|
|
21
|
+
}
|
|
22
|
+
export function validateApiKey(apiKey, minLength = 10) {
|
|
23
|
+
return typeof apiKey === 'string' && apiKey.length >= minLength;
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@imazhar101/mcp-bigquery-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "BigQuery MCP server — read-only query execution, schema exploration, dataset discovery",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/servers/bigquery/src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-bigquery": "dist/servers/bigquery/src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@google-cloud/bigquery": "7.9.4",
|
|
16
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "20.0.0",
|
|
20
|
+
"typescript": "5.9.3"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
]
|
|
25
|
+
}
|