tools.js•25.1 kB
import { z } from 'zod';
import { getProjectId } from '../../utils/auth.js';
import { getSpannerClient, getSpannerConfig } from './types.js';
import { getSpannerSchema } from './schema.js';
import { stateManager } from '../../utils/state-manager.js';
/**
* Get detailed schema information for a Spanner database in a format suitable for query generation
*
* @param instanceId Spanner instance ID
* @param databaseId Spanner database ID
* @returns Detailed schema information with table relationships
*/
async function getDetailedSchemaForQueryGeneration(instanceId, databaseId) {
const schema = await getSpannerSchema(instanceId, databaseId);
// Format the schema in a way that's useful for SQL generation
let schemaText = `Database: ${databaseId}\n\nTables:\n`;
for (const table of schema.tables) {
schemaText += `\nTable: ${table.name}\n`;
schemaText += `Columns:\n`;
for (const column of table.columns) {
schemaText += ` - ${column.name}: ${column.type}${column.nullable ? ' (nullable)' : ''}\n`;
}
if (table.indexes && table.indexes.length > 0) {
schemaText += `Indexes:\n`;
for (const index of table.indexes) {
schemaText += ` - ${index.name}: ${index.columns.join(', ')}${index.unique ? ' (unique)' : ''}\n`;
}
}
if (table.foreignKeys && table.foreignKeys.length > 0) {
schemaText += `Foreign Keys:\n`;
for (const fk of table.foreignKeys) {
schemaText += ` - ${fk.name}: ${fk.columns.join(', ')} → ${fk.referencedTable}(${fk.referencedColumns.join(', ')})\n`;
}
}
}
return schemaText;
}
export function registerSpannerTools(server) {
// Tool to execute SQL queries
server.tool('execute-spanner-query', {
sql: z.string().describe('The SQL query to execute'),
instanceId: z.string().optional().describe('Spanner instance ID (defaults to SPANNER_INSTANCE env var)'),
databaseId: z.string().optional().describe('Spanner database ID (defaults to SPANNER_DATABASE env var)'),
params: z.record(z.any()).optional().describe('Query parameters')
}, async ({ sql, instanceId, databaseId, params }, _extra) => {
try {
const projectId = await getProjectId();
const config = await getSpannerConfig(Array.isArray(instanceId) ? instanceId[0] : instanceId, Array.isArray(databaseId) ? databaseId[0] : databaseId);
const spanner = await getSpannerClient();
console.log(`Using Spanner client with project ID: ${spanner.projectId} for execute-spanner-query`);
const instance = spanner.instance(config.instanceId);
const database = instance.database(config.databaseId);
// Execute the query
const [result] = await database.run({
sql,
params: params || {}
});
if (!result || result.length === 0) {
return {
content: [{
type: 'text',
text: `# Query Results\n\nProject: ${projectId}\nInstance: ${config.instanceId}\nDatabase: ${config.databaseId}\n\nQuery executed successfully. No results returned.`
}]
};
}
// Convert to markdown table
const columns = Object.keys(result[0]);
let markdown = `# Query Results\n\nProject: ${projectId}\nInstance: ${config.instanceId}\nDatabase: ${config.databaseId}\n\n`;
markdown += `SQL: \`${sql}\`\n\n`;
markdown += `Rows: ${result.length}\n\n`;
// Table header
markdown += '| ' + columns.join(' | ') + ' |\n';
markdown += '| ' + columns.map(() => '---').join(' | ') + ' |\n';
// Table rows (limit to 100 rows for display)
const displayRows = result.slice(0, 100);
for (const row of displayRows) {
const rowValues = columns.map(col => {
const value = row[col];
if (value === null || value === undefined)
return 'NULL';
if (typeof value === 'object')
return JSON.stringify(value);
return String(value);
});
markdown += '| ' + rowValues.join(' | ') + ' |\n';
}
if (result.length > 100) {
markdown += '\n*Results truncated. Showing 100 of ' + result.length + ' rows.*';
}
return {
content: [{
type: 'text',
text: markdown
}]
};
}
catch (error) {
console.error('Error executing Spanner query:', error);
throw error;
}
});
// Tool to list tables
server.tool('list-spanner-tables', {
instanceId: z.string().optional().describe('Spanner instance ID (defaults to SPANNER_INSTANCE env var)'),
databaseId: z.string().optional().describe('Spanner database ID (defaults to SPANNER_DATABASE env var)')
}, async ({ instanceId, databaseId }, _extra) => {
try {
const projectId = await getProjectId();
const config = await getSpannerConfig(Array.isArray(instanceId) ? instanceId[0] : instanceId, Array.isArray(databaseId) ? databaseId[0] : databaseId);
const spanner = await getSpannerClient();
console.log(`Using Spanner client with project ID: ${spanner.projectId} for execute-spanner-query`);
const instance = spanner.instance(config.instanceId);
const database = instance.database(config.databaseId);
// Query for tables
// Execute query to list tables
const [tablesResult] = await database.run({
sql: `SELECT t.table_name,
(SELECT COUNT(1) FROM information_schema.columns
WHERE table_name = t.table_name) as column_count
FROM information_schema.tables t
WHERE t.table_catalog = '' AND t.table_schema = ''
ORDER BY t.table_name`
});
if (!tablesResult || tablesResult.length === 0) {
return {
content: [{
type: 'text',
text: `# Spanner Tables\n\nProject: ${projectId}\nInstance: ${config.instanceId}\nDatabase: ${config.databaseId}\n\nNo tables found in the database.`
}]
};
}
let markdown = `# Spanner Tables\n\nProject: ${projectId}\nInstance: ${config.instanceId}\nDatabase: ${config.databaseId}\n\n`;
// Table header
markdown += '| Table Name | Column Count |\n';
markdown += '|------------|-------------|\n';
// Table rows
for (const row of tablesResult) {
// Access the row properties directly
// Extract table name and column count
const tableName = row.table_name || 'unknown';
const columnCount = row.column_count || 0;
markdown += `| ${tableName} | ${columnCount} |\n`;
}
// Add resource links for further exploration
markdown += '\n## Available Resources\n\n';
markdown += `- Schema: \`gcp-spanner://${projectId}/${config.instanceId}/${config.databaseId}/schema\`\n`;
for (const row of tablesResult) {
const tableName = row.table_name || 'unknown';
markdown += `- Table Preview: \`gcp-spanner://${projectId}/${config.instanceId}/${config.databaseId}/tables/${tableName}/preview\`\n`;
}
return {
content: [{
type: 'text',
text: markdown
}]
};
}
catch (error) {
console.error('Error listing Spanner tables:', error);
throw error;
}
});
// Tool to list instances
server.tool('list-spanner-instances',
// Define an empty schema with a dummy parameter that's optional
// This ensures compatibility with clients that expect an object parameter
{
_dummy: z.string().optional().describe('Not used, just to ensure parameter compatibility')
}, async (_params, _extra) => {
try {
// First try to get the project ID from the state manager
let projectId = stateManager.getCurrentProjectId();
if (projectId) {
console.log(`Got project ID from state manager: ${projectId}`);
}
else {
// If not in state manager, try to get it from environment
const envProjectId = process.env.GOOGLE_CLOUD_PROJECT;
if (envProjectId) {
projectId = envProjectId;
console.log(`Got project ID from environment: ${projectId}`);
// Store in state manager for future use
await stateManager.setCurrentProjectId(projectId);
}
else {
// If not in environment, try to get it from our function
projectId = await getProjectId();
console.log(`Got project ID from getProjectId: ${projectId}`);
}
}
if (!projectId) {
throw new Error('Project ID could not be determined. Please set a project ID using the set-project-id tool.');
}
// Create Spanner client with explicit project ID
const spanner = new (await import('@google-cloud/spanner')).Spanner({
projectId: projectId
});
console.log(`Using Spanner client with explicit project ID: ${projectId} for list-spanner-instances`);
const [instances] = await spanner.getInstances();
if (!instances || instances.length === 0) {
return {
content: [{
type: 'text',
text: `# Spanner Instances\n\nProject: ${projectId}\n\nNo instances found in the project.`
}]
};
}
let markdown = `# Spanner Instances\n\nProject: ${projectId}\n\n`;
// Table header
markdown += '| Instance ID | State | Config | Nodes |\n';
markdown += '|-------------|-------|--------|-------|\n';
// Table rows
for (const instance of instances) {
const metadata = instance.metadata || {};
markdown += `| ${instance.id || 'unknown'} | ${metadata.state || 'unknown'} | ${metadata.config?.split('/').pop() || 'unknown'} | ${metadata.nodeCount || 'unknown'} |\n`;
}
// Add resource links for further exploration
markdown += '\n## Available Resources\n\n';
markdown += `- All Instances: \`gcp-spanner://${projectId}/instances\`\n`;
for (const instance of instances) {
markdown += `- Databases in ${instance.id}: \`gcp-spanner://${projectId}/${instance.id}/databases\`\n`;
}
return {
content: [{
type: 'text',
text: markdown
}]
};
}
catch (error) {
console.error('Error listing Spanner instances:', error);
throw error;
}
});
// Tool to list databases
server.tool('list-spanner-databases', {
instanceId: z.string().describe('Spanner instance ID')
}, async ({ instanceId }, _extra) => {
try {
// First try to get the project ID from the state manager
let projectId = stateManager.getCurrentProjectId();
if (!projectId) {
// If not in state manager, try to get it from our function
const authProjectId = await getProjectId();
if (authProjectId) {
projectId = authProjectId;
console.log(`Got project ID from getProjectId: ${projectId}`);
}
}
else {
console.log(`Got project ID from state manager: ${projectId}`);
}
if (!projectId) {
throw new Error('Project ID could not be determined. Please set a project ID using the set-project-id tool.');
}
// Create Spanner client with explicit project ID
const spanner = new (await import('@google-cloud/spanner')).Spanner({
projectId: projectId
});
console.log(`Using Spanner client with project ID: ${projectId} for list-spanner-databases`);
const instance = spanner.instance(Array.isArray(instanceId) ? instanceId[0] : instanceId);
const [databases] = await instance.getDatabases();
if (!databases || databases.length === 0) {
return {
content: [{
type: 'text',
text: `# Spanner Databases\n\nProject: ${projectId}\nInstance: ${Array.isArray(instanceId) ? instanceId[0] : instanceId}\n\nNo databases found in the instance.`
}]
};
}
let markdown = `# Spanner Databases\n\nProject: ${projectId}\nInstance: ${Array.isArray(instanceId) ? instanceId[0] : instanceId}\n\n`;
// Table header
markdown += '| Database ID | State |\n';
markdown += '|-------------|-------|\n';
// Table rows
for (const database of databases) {
const metadata = database.metadata || {};
markdown += `| ${database.id || 'unknown'} | ${metadata.state || 'unknown'} |\n`;
}
// Add resource links for further exploration
markdown += '\n## Available Resources\n\n';
for (const database of databases) {
markdown += `- Tables in ${database.id}: \`gcp-spanner://${projectId}/${Array.isArray(instanceId) ? instanceId[0] : instanceId}/${database.id}/tables\`\n`;
markdown += `- Schema for ${database.id}: \`gcp-spanner://${projectId}/${Array.isArray(instanceId) ? instanceId[0] : instanceId}/${database.id}/schema\`\n`;
}
return {
content: [{
type: 'text',
text: markdown
}]
};
}
catch (error) {
console.error('Error listing Spanner databases:', error);
throw error;
}
});
// Tool to execute natural language queries against Spanner
server.tool('natural-language-spanner-query', {
query: z.string().describe('Natural language description of the query you want to execute'),
instanceId: z.string().optional().describe('Spanner instance ID (defaults to SPANNER_INSTANCE env var)'),
databaseId: z.string().optional().describe('Spanner database ID (defaults to SPANNER_DATABASE env var)')
}, async ({ query, instanceId, databaseId }, _extra) => {
try {
// First try to get the project ID from the state manager
let projectId = stateManager.getCurrentProjectId();
if (!projectId) {
// If not in state manager, try to get it from our function
const authProjectId = await getProjectId();
if (authProjectId) {
projectId = authProjectId;
console.log(`Got project ID from getProjectId: ${projectId}`);
}
}
else {
console.log(`Got project ID from state manager: ${projectId}`);
}
if (!projectId) {
throw new Error('Project ID could not be determined. Please set a project ID using the set-project-id tool.');
}
const config = await getSpannerConfig(Array.isArray(instanceId) ? instanceId[0] : instanceId, Array.isArray(databaseId) ? databaseId[0] : databaseId);
// Get the schema for the database
const schemaInfo = await getDetailedSchemaForQueryGeneration(config.instanceId, config.databaseId);
// Create Spanner client with explicit project ID
const spanner = new (await import('@google-cloud/spanner')).Spanner({
projectId: projectId
});
console.log(`Using Spanner client with project ID: ${projectId} for execute-spanner-query`);
const instance = spanner.instance(config.instanceId);
const database = instance.database(config.databaseId);
const [tablesResult] = await database.run({
sql: `SELECT table_name FROM information_schema.tables
WHERE table_catalog = '' AND table_schema = ''
ORDER BY table_name`
});
const tableNames = tablesResult.map((row) => row.table_name);
if (!tableNames || tableNames.length === 0) {
return {
content: [{
type: 'text',
text: `# Query Error\n\nNo tables found in database ${config.databaseId}.`
}]
};
}
// Generate a SQL query based on the natural language query and schema
let generatedSql = '';
// For simple queries about table structure, generate SQL directly
if (query.toLowerCase().includes('list tables') ||
query.toLowerCase().includes('show tables') ||
query.toLowerCase().includes('what tables')) {
generatedSql = `SELECT table_name FROM information_schema.tables
WHERE table_catalog = '' AND table_schema = ''
ORDER BY table_name`;
}
else if (query.toLowerCase().includes('schema') ||
query.toLowerCase().includes('structure') ||
query.toLowerCase().includes('columns')) {
// Extract table name if specified
let tableName = '';
for (const name of tableNames) {
if (query.toLowerCase().includes(name.toLowerCase())) {
tableName = name;
break;
}
}
if (tableName) {
generatedSql = `SELECT column_name, spanner_type, is_nullable
FROM information_schema.columns
WHERE table_catalog = '' AND table_schema = '' AND table_name = '${tableName}'
ORDER BY ordinal_position`;
}
else {
generatedSql = `SELECT table_name, column_name, spanner_type
FROM information_schema.columns
WHERE table_catalog = '' AND table_schema = ''
ORDER BY table_name, ordinal_position`;
}
}
// For simple 'show all data' queries
else if (query.toLowerCase().includes('all data') ||
query.toLowerCase().includes('all rows')) {
// Extract table name if specified
let tableName = '';
for (const name of tableNames) {
if (query.toLowerCase().includes(name.toLowerCase())) {
tableName = name;
break;
}
}
if (tableName) {
generatedSql = `SELECT * FROM ${tableName} LIMIT 100`;
}
else {
// If no specific table mentioned, return an error
return {
content: [{
type: 'text',
text: `# Query Error\n\nPlease specify which table you want to see data from. Available tables: ${tableNames.join(', ')}`
}]
};
}
}
// For count queries
else if (query.toLowerCase().includes('count') ||
query.toLowerCase().includes('how many')) {
// Extract table name if specified
let tableName = '';
for (const name of tableNames) {
if (query.toLowerCase().includes(name.toLowerCase())) {
tableName = name;
break;
}
}
if (tableName) {
generatedSql = `SELECT COUNT(*) as count FROM ${tableName}`;
}
else {
// If no specific table mentioned, count rows in all tables
const countQueries = tableNames.map(name => `SELECT '${name}' as table_name, COUNT(*) as row_count FROM ${name}`);
generatedSql = countQueries.join(' UNION ALL ');
}
}
// For more complex queries, provide schema information and ask the user to use execute-spanner-query
else {
return {
content: [{
type: 'text',
text: `# Complex Query Detected\n\nYour query requires a custom SQL statement. Here's the database schema to help you formulate your query:\n\n\`\`\`\n${schemaInfo}\n\`\`\`\n\nPlease use the \`execute-spanner-query\` tool with a specific SQL statement to query this data.\n\nExample:\n\`\`\`sql\nSELECT * FROM [table_name] WHERE [condition] LIMIT 100\n\`\`\``
}]
};
}
// Execute the generated SQL query
// Execute the generated SQL query
const [result] = await database.run({
sql: generatedSql
});
if (!result || result.length === 0) {
return {
content: [{
type: 'text',
text: `# Query Results\n\nProject: ${projectId}\nInstance: ${config.instanceId}\nDatabase: ${config.databaseId}\n\nNatural Language Query: ${query}\n\nGenerated SQL: \`${generatedSql}\`\n\nQuery executed successfully. No results returned.`
}]
};
}
// Convert to markdown table
const columns = Object.keys(result[0]);
let markdown = `# Query Results\n\nProject: ${projectId}\nInstance: ${config.instanceId}\nDatabase: ${config.databaseId}\n\n`;
markdown += `Natural Language Query: ${query}\n\n`;
markdown += `Generated SQL: \`${generatedSql}\`\n\n`;
markdown += `Rows: ${result.length}\n\n`;
// Table header
markdown += '| ' + columns.join(' | ') + ' |\n';
markdown += '| ' + columns.map(() => '---').join(' | ') + ' |\n';
// Table rows (limit to 100 rows for display)
const displayRows = result.slice(0, 100);
for (const row of displayRows) {
const rowValues = columns.map(col => {
const value = row[col];
if (value === null || value === undefined)
return 'NULL';
if (typeof value === 'object')
return JSON.stringify(value);
return String(value);
});
markdown += '| ' + rowValues.join(' | ') + ' |\n';
}
if (result.length > 100) {
markdown += '\n*Results truncated. Showing 100 of ' + result.length + ' rows.*';
}
// Add a note about using execute-spanner-query for more complex queries
markdown += '\n\n## Need a more complex query?\n\n';
markdown += 'For more complex queries, use the `execute-spanner-query` tool with a specific SQL statement.';
return {
content: [{
type: 'text',
text: markdown
}]
};
}
catch (error) {
console.error('Error executing natural language Spanner query:', error);
return {
content: [{
type: 'text',
text: `# Query Error\n\nFailed to execute query: ${error.message}\n\nIf this is a complex query, please use the \`execute-spanner-query\` tool with a specific SQL statement.`
}]
};
}
});
}
//# sourceMappingURL=tools.js.map