diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..5424b47 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,3 @@ +export { PortableSSEServerTransport } from './portableSseTransport.js'; +export { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js'; +export { SQLiteCloudMcpServer } from './server.js'; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..5424b47 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,3 @@ +export { PortableSSEServerTransport } from './portableSseTransport.js'; +export { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js'; +export { SQLiteCloudMcpServer } from './server.js'; diff --git a/dist/main.d.ts b/dist/main.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/dist/main.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/dist/main.js b/dist/main.js new file mode 100755 index 0000000..b838974 --- /dev/null +++ b/dist/main.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SQLiteCloudMcpServer } from './server.js'; +import { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js'; +import { parseArgs } from 'util'; +const server = new SQLiteCloudMcpServer(); +async function main() { + // console.debug('Starting SQLite Cloud MCP Server...') + const { values: { connectionString } } = parseArgs({ + options: { + connectionString: { + type: 'string' + } + } + }); + if (!connectionString) { + throw new Error('Please provide a Connection String with the --connectionString flag'); + } + const transport = new SQLiteCloudMcpTransport(connectionString, new StdioServerTransport()); + await server.connect(transport); + // console.debug('SQLite Cloud MCP Server running on stdio') +} +main().catch(error => { + console.error('Fatal error in main():', error); + process.exit(1); +}); diff --git a/dist/portableSseTransport.d.ts b/dist/portableSseTransport.d.ts new file mode 100644 index 0000000..6e7d275 --- /dev/null +++ b/dist/portableSseTransport.d.ts @@ -0,0 +1,49 @@ +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +interface PortableWriter { + write: (message: string) => Promise; + close: () => void; +} +/** + * Server transport for SSE to send messages over an SSE connection. + * + * This is a reimplementation of the `SSEServerTransport` class from `@modelcontextprotocol/sdk/server/see` + * without the dependency with ExpressJS. + */ +export declare class PortableSSEServerTransport implements Transport { + private _endpoint; + private writableStream; + private _sseWriter?; + private _sessionId; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: { + authInfo?: AuthInfo; + }) => void; + /** + * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. + */ + constructor(_endpoint: string, writableStream: PortableWriter); + /** + * Handles the initial SSE connection request. + * + * This should be called when a GET request is made to establish the SSE stream. + */ + start(): Promise; + /** + * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. + */ + handleMessage(message: unknown, extra?: { + authInfo?: AuthInfo; + }): Promise; + close(): Promise; + send(message: JSONRPCMessage): Promise; + /** + * Returns the session ID for this transport. + * + * This can be used to route incoming POST requests. + */ + get sessionId(): string; +} +export {}; diff --git a/dist/portableSseTransport.js b/dist/portableSseTransport.js new file mode 100644 index 0000000..0ff0987 --- /dev/null +++ b/dist/portableSseTransport.js @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js'; +/** + * Server transport for SSE to send messages over an SSE connection. + * + * This is a reimplementation of the `SSEServerTransport` class from `@modelcontextprotocol/sdk/server/see` + * without the dependency with ExpressJS. + */ +export class PortableSSEServerTransport { + _endpoint; + writableStream; + _sseWriter; + _sessionId; + onclose; + onerror; + onmessage; + /** + * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. + */ + constructor(_endpoint, writableStream) { + this._endpoint = _endpoint; + this.writableStream = writableStream; + this._sessionId = randomUUID(); + } + /** + * Handles the initial SSE connection request. + * + * This should be called when a GET request is made to establish the SSE stream. + */ + async start() { + if (this._sseWriter) { + throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.'); + } + this._sseWriter = this.writableStream; + // Send the endpoint event + // Use a dummy base URL because this._endpoint is relative. + // This allows using URL/URLSearchParams for robust parameter handling. + const dummyBase = 'http://localhost'; // Any valid base works + const endpointUrl = new URL(this._endpoint, dummyBase); + endpointUrl.searchParams.set('sessionId', this._sessionId); + // Reconstruct the relative URL string (pathname + search + hash) + const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; + this._sseWriter?.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`); + } + /** + * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. + */ + async handleMessage(message, extra) { + if (!this._sseWriter) { + const message = 'SSE connection not established'; + throw new Error(message); + } + let parsedMessage; + try { + parsedMessage = JSONRPCMessageSchema.parse(message); + this.onmessage?.(parsedMessage, extra); + } + catch (error) { + this.onerror?.(error); + throw error; + } + } + async close() { + this._sseWriter?.close(); + this._sseWriter = undefined; + this.writableStream.close(); + this.onclose?.(); + } + async send(message) { + if (!this._sseWriter) { + throw new Error('Not connected'); + } + this._sseWriter.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } + /** + * Returns the session ID for this transport. + * + * This can be used to route incoming POST requests. + */ + get sessionId() { + return this._sessionId; + } +} diff --git a/dist/server.d.ts b/dist/server.d.ts new file mode 100644 index 0000000..8539616 --- /dev/null +++ b/dist/server.d.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js'; +export declare class SQLiteCloudMcpServer { + private mcpServer; + private registry; + constructor(); + connect(transport: SQLiteCloudMcpTransport): Promise; + getTransport(sessionId: string): SQLiteCloudMcpTransport; + addCustomTool(name: string, description: string, parameters: z.ZodRawShape, handler: (parameters: any, transport: SQLiteCloudMcpTransport) => Promise): void; + removeCustomTool(name: string): void; + private initializeServer; + private setupServer; +} diff --git a/dist/server.js b/dist/server.js new file mode 100644 index 0000000..9e800b8 --- /dev/null +++ b/dist/server.js @@ -0,0 +1,204 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +export class SQLiteCloudMcpServer { + mcpServer; + registry; + constructor() { + this.registry = {}; + this.mcpServer = this.initializeServer(); + this.setupServer(); + } + async connect(transport) { + const mcpTransport = transport.mcpTransport; + let sessionId = mcpTransport.sessionId; + if (!sessionId) { + sessionId = 'anonymous'; + mcpTransport.sessionId = sessionId; + } + mcpTransport.onerror = error => { + console.error('Error in transport:', error); + delete this.registry[sessionId]; + }; + mcpTransport.onclose = () => { + delete this.registry[sessionId]; + }; + this.registry[sessionId] = transport; + await this.mcpServer.connect(mcpTransport); + } + getTransport(sessionId) { + const transport = this.registry[sessionId]; + if (!transport) { + throw new Error(`Transport not found for session ID: ${sessionId}`); + } + return transport; + } + addCustomTool(name, description, parameters, handler) { + // TODO: keep a registered list of tools to check existence and to implement removal + this.mcpServer.tool(name, description, parameters, async (parameters, extra) => { + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const customerResult = await handler(parameters, this.getTransport(extra.sessionId)); + return { content: [{ type: 'text', text: JSON.stringify(customerResult) }] }; + }); + } + removeCustomTool(name) { + throw new Error('Not implemented'); + } + initializeServer() { + return new McpServer({ + name: 'sqlitecloud-mcp-server', + version: '0.0.1', + description: 'MCP Server for SQLite Cloud: https://sqlitecloud.io' + }, { + capabilities: { tools: {} }, + instructions: 'This server provides tools to interact with SQLite databases on SQLite Cloud, execute SQL queries, manage table schemas and analyze performance metrics.' + }); + } + setupServer() { + this.mcpServer.tool('read-query', 'Execute a SELECT query on the SQLite database on SQLite Cloud', { + query: z.string().describe('SELECT SQL query to execute') + }, async ({ query }, extra) => { + if (!query.trim().toUpperCase().startsWith('SELECT')) { + throw new Error('Only SELECT queries are allowed for read-query'); + } + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(query); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('write-query', 'Execute a INSERT, UPDATE, or DELETE query on the SQLite database on SQLite Cloud', { + query: z.string().describe('SELECT SQL query to execute') + }, async ({ query }, extra) => { + if (query.trim().toUpperCase().startsWith('SELECT')) { + throw new Error('SELECT queries are not allowed for write_query'); + } + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(query); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('create-table', 'Create a new table in the SQLite database on SQLite Cloud', { + query: z.string().describe('CREATE TABLE SQL statement') + }, async ({ query }, extra) => { + if (!query.trim().toUpperCase().startsWith('CREATE TABLE')) { + throw new Error('Only CREATE TABLE statements are allowed'); + } + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(query); + return { + content: [{ type: 'text', text: 'Table created successfully' }] + }; + }); + this.mcpServer.tool('list-tables', 'List all tables in the SQLite database on SQLite Cloud', {}, async ({}, extra) => { + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery("SELECT name FROM sqlite_master WHERE type='table'"); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('describe-table', 'Get the schema information for a specific table on SQLite Cloud database', { + tableName: z.string().describe('Name of the table to describe') + }, async ({ tableName }, extra) => { + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(`PRAGMA table_info(${tableName})`); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('list-commands', 'List all available commands and their descriptions from the SQLite database and an external documentation page.', {}, async ({}, extra) => { + try { + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery('LIST COMMANDS;'); + // Download the documentation page + const documentationUrl = 'https://raw.githubusercontent.com/sqlitecloud/docs/refs/heads/main/sqlite-cloud/reference/general-commands.mdx'; + const response = await fetch(documentationUrl, { + redirect: 'follow' + }); + const documentationContent = await response.text(); + return { + content: [ + { type: 'text', text: JSON.stringify(results) }, + { type: 'text', text: documentationContent } + ] + }; + } + catch (error) { + throw new Error('Failed to list commands and fetch documentation.', { cause: error }); + } + }); + this.mcpServer.tool('execute-command', 'Execute only SQLite Cloud commands listed in the `list-commands` tool. You can use the `list-commands` tool to see the available commands.', { + command: z.string().describe('SQLite Cloud available command to execute') + }, async ({ command }, extra) => { + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(command); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('list-analyzer', 'Returns a rowset with the slowest queries performed on the connected this.mcpServer. Supports filtering with GROUPID, DATABASE, GROUPED, and NODE options.', { + groupId: z.string().optional().describe('Group ID to filter the results'), + database: z.string().optional().describe('Database name to filter the results'), + grouped: z.boolean().optional().describe('Whether to group the slowest queries'), + node: z.string().optional().describe('Node ID to execute the command on a specific cluster node') + }, async ({ groupId, database, grouped, node }, extra) => { + let query = 'LIST ANALYZER'; + if (groupId) + query += ` GROUPID ${groupId}`; + if (database) + query += ` DATABASE ${database}`; + if (grouped) + query += ' GROUPED'; + if (node) + query += ` NODE ${node}`; + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(query); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('analyzer-plan-id', 'Gathers information about the indexes used in the query plan of a query execution.', { + queryId: z.string().describe('Query ID to analyze'), + node: z.string().optional().describe('SQLite Cloud Node ID to execute the command on a specific cluster node') + }, async ({ queryId, node }, extra) => { + let query = `ANALYZER PLAN ID ${queryId}`; + if (node) + query += ` NODE ${node}`; + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(query); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + this.mcpServer.tool('analyzer-reset', 'Resets the statistics about a specific query, group of queries, or database.', { + queryId: z.string().optional().describe('Query ID to reset'), + groupId: z.string().optional().describe('Group ID to reset'), + database: z.string().optional().describe('Database name to reset'), + all: z.boolean().optional().describe('Whether to reset all statistics'), + node: z.string().optional().describe('SQLite Cloud Node ID to execute the command on a specific cluster node') + }, async ({ queryId, groupId, database, all, node }, extra) => { + let query = 'ANALYZER RESET'; + if (queryId) + query += ` ID ${queryId}`; + if (groupId) + query += ` GROUPID ${groupId}`; + if (database) + query += ` DATABASE ${database}`; + if (all) + query += ' ALL'; + if (node) + query += ` NODE ${node}`; + if (!extra.sessionId) { + throw new Error('Session ID is required'); + } + const results = await this.getTransport(extra.sessionId).executeQuery(query); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }); + } +} diff --git a/dist/sqlitecloudTransport.d.ts b/dist/sqlitecloudTransport.d.ts new file mode 100644 index 0000000..bca4065 --- /dev/null +++ b/dist/sqlitecloudTransport.d.ts @@ -0,0 +1,8 @@ +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +export declare class SQLiteCloudMcpTransport { + private connectionString; + mcpTransport: Transport; + constructor(connectionString: string, mcpTransport: Transport); + private getDatabase; + executeQuery(query: string): Promise; +} diff --git a/dist/sqlitecloudTransport.js b/dist/sqlitecloudTransport.js new file mode 100644 index 0000000..18add4d --- /dev/null +++ b/dist/sqlitecloudTransport.js @@ -0,0 +1,26 @@ +import { Database } from '@sqlitecloud/drivers'; +export class SQLiteCloudMcpTransport { + connectionString; + mcpTransport; + constructor(connectionString, mcpTransport) { + this.connectionString = connectionString; + this.mcpTransport = mcpTransport; + } + getDatabase() { + return new Database(this.connectionString, err => { + if (err) { + console.error('Error opening database:', err); + throw err; + } + }); + } + async executeQuery(query) { + const db = this.getDatabase(); + try { + return db.sql(query); + } + finally { + db.close(); + } + } +} diff --git a/package-lock.json b/package-lock.json index 6e793e1..986b5d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,27 @@ { "name": "@sqlitecloud/mcp-server", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sqlitecloud/mcp-server", - "version": "0.1.1", + "version": "0.1.2", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@sqlitecloud/drivers": "^1.0.438", "zod": "^3.24.2" }, "bin": { - "mcp-server": "build/index.js" + "mcp-server": "dist/main.js" }, "devDependencies": { "@types/node": "^22.14.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=16.0" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index adf0249..bbaef7d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,13 @@ { "name": "@sqlitecloud/mcp-server", - "version": "0.1.1", + "version": "0.1.2", "description": "Model Context Protocol server for SQLite Cloud database", - "author": "SQLite Cloud", + "license": "MIT", + "author": { + "name": "SQLite Cloud, Inc.", + "email": "support@sqlitecloud.io", + "url": "https://sqlitecloud.io/" + }, "homepage": "https://sqlite.ai", "repository": { "url": "https://github.com/sqlitecloud/sqlitecloud-mcp-server", @@ -17,18 +22,22 @@ "database", "cloud" ], - "main": "./build/index.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "engines": { + "node": ">=16.0" + }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "rm -rf build && tsc && chmod 755 build/index.js", + "build": "rm -rf ./dist && tsc && chmod 755 dist/main.js", "publish": "npm run build && npm publish --access public" }, "type": "module", "bin": { - "mcp-server": "./build/index.js" + "mcp-server": "./dist/main.js" }, "files": [ - "build" + "dist" ], "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", diff --git a/readme.md b/readme.md index 05388d8..7aa14aa 100644 --- a/readme.md +++ b/readme.md @@ -117,7 +117,7 @@ npm run build After building the package, run it with: ```bash -node build/index.js --connectionString +node dist/main.js --connectionString ``` ### Local Testing @@ -150,7 +150,7 @@ Access the inspector at: [http://127.0.0.1:6274/](http://127.0.0.1:6274/) - **Transport Type**: `stdio` - **Command**: `npx` -- **Arguments**: ` --connectionString ` +- **Arguments**: `~/ --connectionString ` _Note: Use the `PATH_TO_PACKAGE_FOLDER` from your home directory to avoid permission issues._ diff --git a/src/index.ts b/src/index.ts index e82dc40..f5405d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,3 @@ -#!/usr/bin/env node - -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { SQLiteCloudMcpServer } from './server.js' -import { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js' -import { parseArgs } from 'util' - -const server = new SQLiteCloudMcpServer() - -async function main() { - // console.debug('Starting SQLite Cloud MCP Server...') - const { - values: { connectionString } - } = parseArgs({ - options: { - connectionString: { - type: 'string' - } - } - }) - - if (!connectionString) { - throw new Error('Please provide a Connection String with the --connectionString flag') - } - - const transport = new SQLiteCloudMcpTransport(connectionString, new StdioServerTransport()) - await server.connect(transport) - // console.debug('SQLite Cloud MCP Server running on stdio') -} - -main().catch(error => { - console.error('Fatal error in main():', error) - process.exit(1) -}) +export { PortableSSEServerTransport } from './portableSseTransport.js' +export { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js' +export { SQLiteCloudMcpServer } from './server.js' \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..e82dc40 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { SQLiteCloudMcpServer } from './server.js' +import { SQLiteCloudMcpTransport } from './sqlitecloudTransport.js' +import { parseArgs } from 'util' + +const server = new SQLiteCloudMcpServer() + +async function main() { + // console.debug('Starting SQLite Cloud MCP Server...') + const { + values: { connectionString } + } = parseArgs({ + options: { + connectionString: { + type: 'string' + } + } + }) + + if (!connectionString) { + throw new Error('Please provide a Connection String with the --connectionString flag') + } + + const transport = new SQLiteCloudMcpTransport(connectionString, new StdioServerTransport()) + await server.connect(transport) + // console.debug('SQLite Cloud MCP Server running on stdio') +} + +main().catch(error => { + console.error('Fatal error in main():', error) + process.exit(1) +}) diff --git a/tsconfig.json b/tsconfig.json index 1c7e3c3..ce6695e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,18 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] - } \ No newline at end of file + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "allowJs": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}