MCP Server Example with PRPs

This commit is contained in:
Cole Medin
2025-07-12 11:48:38 -05:00
parent 73d08d2236
commit 8445f05b67
42 changed files with 18567 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { validateSqlQuery, isWriteOperation, formatDatabaseError } from '../../../src/database/security'
import {
validSelectQuery,
validInsertQuery,
validUpdateQuery,
validDeleteQuery,
dangerousDropQuery,
dangerousDeleteAllQuery,
maliciousInjectionQuery,
emptyQuery,
whitespaceQuery,
} from '../../fixtures/database.fixtures'
describe('Database Security', () => {
describe('validateSqlQuery', () => {
it('should validate safe SELECT queries', () => {
const result = validateSqlQuery(validSelectQuery)
expect(result.isValid).toBe(true)
expect(result.error).toBeUndefined()
})
it('should validate safe INSERT queries', () => {
const result = validateSqlQuery(validInsertQuery)
expect(result.isValid).toBe(true)
expect(result.error).toBeUndefined()
})
it('should reject empty queries', () => {
const result = validateSqlQuery(emptyQuery)
expect(result.isValid).toBe(false)
expect(result.error).toBe('SQL query cannot be empty')
})
it('should reject whitespace-only queries', () => {
const result = validateSqlQuery(whitespaceQuery)
expect(result.isValid).toBe(false)
expect(result.error).toBe('SQL query cannot be empty')
})
it('should reject dangerous DROP queries', () => {
const result = validateSqlQuery(dangerousDropQuery)
expect(result.isValid).toBe(false)
expect(result.error).toBe('Query contains potentially dangerous SQL patterns')
})
it('should reject dangerous DELETE ALL queries', () => {
const result = validateSqlQuery(dangerousDeleteAllQuery)
expect(result.isValid).toBe(false)
expect(result.error).toBe('Query contains potentially dangerous SQL patterns')
})
it('should reject SQL injection attempts', () => {
const result = validateSqlQuery(maliciousInjectionQuery)
expect(result.isValid).toBe(false)
expect(result.error).toBe('Query contains potentially dangerous SQL patterns')
})
it('should handle case-insensitive dangerous patterns', () => {
const upperCaseQuery = 'SELECT * FROM users; DROP TABLE users;'
const result = validateSqlQuery(upperCaseQuery)
expect(result.isValid).toBe(false)
expect(result.error).toBe('Query contains potentially dangerous SQL patterns')
})
})
describe('isWriteOperation', () => {
it('should identify SELECT as read operation', () => {
expect(isWriteOperation(validSelectQuery)).toBe(false)
})
it('should identify INSERT as write operation', () => {
expect(isWriteOperation(validInsertQuery)).toBe(true)
})
it('should identify UPDATE as write operation', () => {
expect(isWriteOperation(validUpdateQuery)).toBe(true)
})
it('should identify DELETE as write operation', () => {
expect(isWriteOperation(validDeleteQuery)).toBe(true)
})
it('should identify DROP as write operation', () => {
expect(isWriteOperation(dangerousDropQuery)).toBe(true)
})
it('should handle case-insensitive operations', () => {
expect(isWriteOperation('insert into users values (1, \'test\')')).toBe(true)
expect(isWriteOperation('UPDATE users SET name = \'test\'')).toBe(true)
expect(isWriteOperation('Delete from users where id = 1')).toBe(true)
})
it('should handle queries with leading whitespace', () => {
expect(isWriteOperation(' INSERT INTO users VALUES (1, \'test\')')).toBe(true)
expect(isWriteOperation('\t\nSELECT * FROM users')).toBe(false)
})
})
describe('formatDatabaseError', () => {
it('should format generic database errors', () => {
const error = new Error('Connection failed')
const result = formatDatabaseError(error)
expect(result).toBe('Database error: Connection failed')
})
it('should sanitize password errors', () => {
const error = new Error('authentication failed for user "test" with password "secret123"')
const result = formatDatabaseError(error)
expect(result).toBe('Database authentication failed. Please check your credentials.')
})
it('should handle timeout errors', () => {
const error = new Error('Connection timeout after 30 seconds')
const result = formatDatabaseError(error)
expect(result).toBe('Database connection timed out. Please try again.')
})
it('should handle connection errors', () => {
const error = new Error('Could not connect to database server')
const result = formatDatabaseError(error)
expect(result).toBe('Unable to connect to database. Please check your connection string.')
})
it('should handle non-Error objects', () => {
const result = formatDatabaseError('string error')
expect(result).toBe('An unknown database error occurred.')
})
it('should handle null/undefined errors', () => {
expect(formatDatabaseError(null)).toBe('An unknown database error occurred.')
expect(formatDatabaseError(undefined)).toBe('An unknown database error occurred.')
})
})
})

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the database connection module
const mockDbInstance = {
unsafe: vi.fn(),
end: vi.fn(),
}
vi.mock('../../../src/database/connection', () => ({
getDb: vi.fn(() => mockDbInstance),
}))
// Now import the modules
import { withDatabase } from '../../../src/database/utils'
describe('Database Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('withDatabase', () => {
it('should execute database operation successfully', async () => {
const mockOperation = vi.fn().mockResolvedValue('success')
const result = await withDatabase('test-url', mockOperation)
expect(result).toBe('success')
expect(mockOperation).toHaveBeenCalledWith(mockDbInstance)
})
it('should handle database operation errors', async () => {
const mockOperation = vi.fn().mockRejectedValue(new Error('Operation failed'))
await expect(withDatabase('test-url', mockOperation)).rejects.toThrow('Operation failed')
expect(mockOperation).toHaveBeenCalledWith(mockDbInstance)
})
it('should log successful operations', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const mockOperation = vi.fn().mockResolvedValue('success')
await withDatabase('test-url', mockOperation)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/Database operation completed successfully in \d+ms/)
)
consoleSpy.mockRestore()
})
it('should log failed operations', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockOperation = vi.fn().mockRejectedValue(new Error('Operation failed'))
await expect(withDatabase('test-url', mockOperation)).rejects.toThrow('Operation failed')
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/Database operation failed after \d+ms:/),
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('should measure execution time', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const mockOperation = vi.fn().mockImplementation(async () => {
// Simulate some delay
await new Promise(resolve => setTimeout(resolve, 10))
return 'success'
})
await withDatabase('test-url', mockOperation)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/Database operation completed successfully in \d+ms/)
)
consoleSpy.mockRestore()
})
})
})

View File

@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the database modules
const mockDbInstance = {
unsafe: vi.fn(),
end: vi.fn(),
}
vi.mock('../../../src/database/connection', () => ({
getDb: vi.fn(() => mockDbInstance),
}))
vi.mock('../../../src/database/utils', () => ({
withDatabase: vi.fn(async (url: string, operation: any) => {
return await operation(mockDbInstance)
}),
}))
// Now import the modules
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { registerDatabaseTools } from '../../../src/tools/database-tools'
import { mockProps, mockPrivilegedProps } from '../../fixtures/auth.fixtures'
import { mockEnv } from '../../mocks/oauth.mock'
import { mockTableColumns, mockQueryResult } from '../../fixtures/database.fixtures'
describe('Database Tools', () => {
let mockServer: McpServer
beforeEach(() => {
vi.clearAllMocks()
mockServer = new McpServer({ name: 'test', version: '1.0.0' })
// Setup database mocks
mockDbInstance.unsafe.mockImplementation((query: string) => {
if (query.includes('information_schema.columns')) {
return Promise.resolve(mockTableColumns)
}
if (query.includes('SELECT')) {
return Promise.resolve(mockQueryResult)
}
if (query.includes('INSERT') || query.includes('UPDATE') || query.includes('DELETE')) {
return Promise.resolve([{ affectedRows: 1 }])
}
return Promise.resolve([])
})
})
describe('registerDatabaseTools', () => {
it('should register listTables and queryDatabase for regular users', () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
expect(toolSpy).toHaveBeenCalledWith(
'listTables',
expect.any(String),
expect.any(Object),
expect.any(Function)
)
expect(toolSpy).toHaveBeenCalledWith(
'queryDatabase',
expect.any(String),
expect.any(Object),
expect.any(Function)
)
expect(toolSpy).toHaveBeenCalledTimes(2)
})
it('should register all tools for privileged users', () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockPrivilegedProps)
expect(toolSpy).toHaveBeenCalledWith(
'listTables',
expect.any(String),
expect.any(Object),
expect.any(Function)
)
expect(toolSpy).toHaveBeenCalledWith(
'queryDatabase',
expect.any(String),
expect.any(Object),
expect.any(Function)
)
expect(toolSpy).toHaveBeenCalledWith(
'executeDatabase',
expect.any(String),
expect.any(Object),
expect.any(Function)
)
expect(toolSpy).toHaveBeenCalledTimes(3)
})
})
describe('listTables tool', () => {
it('should return table schema successfully', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
// Get the registered tool handler
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'listTables')
const handler = toolCall![3] as Function
const result = await handler({})
expect(result.content).toBeDefined()
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toContain('Database Tables and Schema')
expect(result.content[0].text).toContain('users')
expect(result.content[0].text).toContain('posts')
})
it('should handle database errors', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
mockDbInstance.unsafe.mockRejectedValue(new Error('Database connection failed'))
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'listTables')
const handler = toolCall![3] as Function
const result = await handler({})
expect(result.content[0].isError).toBe(true)
expect(result.content[0].text).toContain('Error')
})
})
describe('queryDatabase tool', () => {
it('should execute SELECT queries successfully', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'queryDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'SELECT * FROM users' })
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toContain('Query Results')
expect(result.content[0].text).toContain('SELECT * FROM users')
})
it('should reject write operations', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'queryDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'INSERT INTO users VALUES (1, \'test\')' })
expect(result.content[0].isError).toBe(true)
expect(result.content[0].text).toContain('Write operations are not allowed')
})
it('should reject invalid SQL', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'queryDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'SELECT * FROM users; DROP TABLE users' })
expect(result.content[0].isError).toBe(true)
expect(result.content[0].text).toContain('Invalid SQL query')
})
it('should handle database errors', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
mockDbInstance.unsafe.mockRejectedValue(new Error('Database connection failed'))
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'queryDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'SELECT * FROM users' })
expect(result.content[0].isError).toBe(true)
expect(result.content[0].text).toContain('Database query error')
})
})
describe('executeDatabase tool', () => {
it('should only be available to privileged users', async () => {
// Regular user should not get executeDatabase
const toolSpy1 = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockProps)
const executeToolCall = toolSpy1.mock.calls.find(call => call[0] === 'executeDatabase')
expect(executeToolCall).toBeUndefined()
// Privileged user should get executeDatabase
const mockServer2 = new McpServer({ name: 'test2', version: '1.0.0' })
const toolSpy2 = vi.spyOn(mockServer2, 'tool')
registerDatabaseTools(mockServer2, mockEnv as any, mockPrivilegedProps)
const privilegedExecuteToolCall = toolSpy2.mock.calls.find(call => call[0] === 'executeDatabase')
expect(privilegedExecuteToolCall).toBeDefined()
})
it('should execute write operations for privileged users', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockPrivilegedProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'executeDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'INSERT INTO users VALUES (1, \'test\')' })
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toContain('Write Operation Executed Successfully')
expect(result.content[0].text).toContain('coleam00')
})
it('should execute read operations for privileged users', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockPrivilegedProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'executeDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'SELECT * FROM users' })
expect(result.content[0].type).toBe('text')
expect(result.content[0].text).toContain('Read Operation Executed Successfully')
})
it('should reject invalid SQL', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
registerDatabaseTools(mockServer, mockEnv as any, mockPrivilegedProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'executeDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'SELECT * FROM users; DROP TABLE users' })
expect(result.content[0].isError).toBe(true)
expect(result.content[0].text).toContain('Invalid SQL statement')
})
it('should handle database errors', async () => {
const toolSpy = vi.spyOn(mockServer, 'tool')
mockDbInstance.unsafe.mockRejectedValue(new Error('Database connection failed'))
registerDatabaseTools(mockServer, mockEnv as any, mockPrivilegedProps)
const toolCall = toolSpy.mock.calls.find(call => call[0] === 'executeDatabase')
const handler = toolCall![3] as Function
const result = await handler({ sql: 'INSERT INTO users VALUES (1, \'test\')' })
expect(result.content[0].isError).toBe(true)
expect(result.content[0].text).toContain('Database execution error')
})
})
})

View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest'
import { createSuccessResponse, createErrorResponse } from '../../../src/types'
describe('Response Helpers', () => {
describe('createSuccessResponse', () => {
it('should create success response with message only', () => {
const response = createSuccessResponse('Operation completed')
expect(response.content).toHaveLength(1)
expect(response.content[0].type).toBe('text')
expect(response.content[0].text).toBe('**Success**\n\nOperation completed')
expect(response.content[0].isError).toBeUndefined()
})
it('should create success response with message and data', () => {
const data = { id: 1, name: 'Test' }
const response = createSuccessResponse('User created', data)
expect(response.content).toHaveLength(1)
expect(response.content[0].type).toBe('text')
expect(response.content[0].text).toContain('**Success**')
expect(response.content[0].text).toContain('User created')
expect(response.content[0].text).toContain('**Result:**')
expect(response.content[0].text).toContain(JSON.stringify(data, null, 2))
})
it('should handle null data', () => {
const response = createSuccessResponse('Operation completed', null)
expect(response.content[0].text).toContain('**Success**')
expect(response.content[0].text).toContain('Operation completed')
expect(response.content[0].text).toContain('**Result:**')
expect(response.content[0].text).toContain('null')
})
it('should handle undefined data', () => {
const response = createSuccessResponse('Operation completed', undefined)
expect(response.content[0].text).toBe('**Success**\n\nOperation completed')
expect(response.content[0].text).not.toContain('**Result:**')
})
it('should handle complex data objects', () => {
const data = {
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
],
meta: {
total: 2,
page: 1
}
}
const response = createSuccessResponse('Users retrieved', data)
expect(response.content[0].text).toContain('**Success**')
expect(response.content[0].text).toContain('Users retrieved')
expect(response.content[0].text).toContain('Alice')
expect(response.content[0].text).toContain('Bob')
expect(response.content[0].text).toContain('total')
})
})
describe('createErrorResponse', () => {
it('should create error response with message only', () => {
const response = createErrorResponse('Something went wrong')
expect(response.content).toHaveLength(1)
expect(response.content[0].type).toBe('text')
expect(response.content[0].text).toBe('**Error**\n\nSomething went wrong')
expect(response.content[0].isError).toBe(true)
})
it('should create error response with message and details', () => {
const details = { code: 'VALIDATION_ERROR', field: 'email' }
const response = createErrorResponse('Validation failed', details)
expect(response.content).toHaveLength(1)
expect(response.content[0].type).toBe('text')
expect(response.content[0].text).toContain('**Error**')
expect(response.content[0].text).toContain('Validation failed')
expect(response.content[0].text).toContain('**Details:**')
expect(response.content[0].text).toContain(JSON.stringify(details, null, 2))
expect(response.content[0].isError).toBe(true)
})
it('should handle null details', () => {
const response = createErrorResponse('Operation failed', null)
expect(response.content[0].text).toContain('**Error**')
expect(response.content[0].text).toContain('Operation failed')
expect(response.content[0].text).toContain('**Details:**')
expect(response.content[0].text).toContain('null')
})
it('should handle undefined details', () => {
const response = createErrorResponse('Operation failed', undefined)
expect(response.content[0].text).toBe('**Error**\n\nOperation failed')
expect(response.content[0].text).not.toContain('**Details:**')
})
it('should handle error objects as details', () => {
const error = new Error('Database connection failed')
const response = createErrorResponse('Database error', error)
expect(response.content[0].text).toContain('**Error**')
expect(response.content[0].text).toContain('Database error')
expect(response.content[0].text).toContain('**Details:**')
expect(response.content[0].isError).toBe(true)
})
it('should handle complex error details', () => {
const details = {
error: 'AUTHENTICATION_FAILED',
message: 'Invalid credentials',
attempts: 3,
nextRetryAt: new Date().toISOString()
}
const response = createErrorResponse('Authentication failed', details)
expect(response.content[0].text).toContain('AUTHENTICATION_FAILED')
expect(response.content[0].text).toContain('Invalid credentials')
expect(response.content[0].text).toContain('attempts')
expect(response.content[0].isError).toBe(true)
})
})
describe('response format consistency', () => {
it('should maintain consistent structure across response types', () => {
const successResponse = createSuccessResponse('Success message')
const errorResponse = createErrorResponse('Error message')
// Both should have the same structure
expect(successResponse.content).toHaveLength(1)
expect(errorResponse.content).toHaveLength(1)
expect(successResponse.content[0].type).toBe('text')
expect(errorResponse.content[0].type).toBe('text')
expect(typeof successResponse.content[0].text).toBe('string')
expect(typeof errorResponse.content[0].text).toBe('string')
})
it('should distinguish between success and error responses', () => {
const successResponse = createSuccessResponse('Success message')
const errorResponse = createErrorResponse('Error message')
expect(successResponse.content[0].isError).toBeUndefined()
expect(errorResponse.content[0].isError).toBe(true)
})
})
})