mirror of
https://github.com/coleam00/context-engineering-intro.git
synced 2025-12-29 16:14:56 +00:00
MCP Server Example with PRPs
This commit is contained in:
45
use-cases/mcp-server/tests/fixtures/auth.fixtures.ts
vendored
Normal file
45
use-cases/mcp-server/tests/fixtures/auth.fixtures.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Props } from '../../src/types'
|
||||
|
||||
export const mockProps: Props = {
|
||||
login: 'testuser',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
accessToken: 'test-access-token',
|
||||
}
|
||||
|
||||
export const mockPrivilegedProps: Props = {
|
||||
login: 'coleam00',
|
||||
name: 'Cole Medin',
|
||||
email: 'cole@example.com',
|
||||
accessToken: 'privileged-access-token',
|
||||
}
|
||||
|
||||
export const mockGitHubUser = {
|
||||
data: {
|
||||
login: 'testuser',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
id: 12345,
|
||||
avatar_url: 'https://github.com/images/avatar.png',
|
||||
},
|
||||
}
|
||||
|
||||
export const mockAuthRequest = {
|
||||
clientId: 'test-client-id',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scope: 'read:user',
|
||||
state: 'test-state',
|
||||
codeChallenge: 'test-challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
}
|
||||
|
||||
export const mockClientInfo = {
|
||||
id: 'test-client-id',
|
||||
name: 'Test Client',
|
||||
description: 'A test OAuth client',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
}
|
||||
|
||||
export const mockAccessToken = 'github-access-token-123'
|
||||
export const mockAuthorizationCode = 'auth-code-456'
|
||||
export const mockState = 'oauth-state-789'
|
||||
64
use-cases/mcp-server/tests/fixtures/database.fixtures.ts
vendored
Normal file
64
use-cases/mcp-server/tests/fixtures/database.fixtures.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
export const mockTableColumns = [
|
||||
{
|
||||
table_name: 'users',
|
||||
column_name: 'id',
|
||||
data_type: 'integer',
|
||||
is_nullable: 'NO',
|
||||
column_default: 'nextval(\'users_id_seq\'::regclass)',
|
||||
},
|
||||
{
|
||||
table_name: 'users',
|
||||
column_name: 'name',
|
||||
data_type: 'character varying',
|
||||
is_nullable: 'YES',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
table_name: 'users',
|
||||
column_name: 'email',
|
||||
data_type: 'character varying',
|
||||
is_nullable: 'NO',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
table_name: 'posts',
|
||||
column_name: 'id',
|
||||
data_type: 'integer',
|
||||
is_nullable: 'NO',
|
||||
column_default: 'nextval(\'posts_id_seq\'::regclass)',
|
||||
},
|
||||
{
|
||||
table_name: 'posts',
|
||||
column_name: 'title',
|
||||
data_type: 'text',
|
||||
is_nullable: 'NO',
|
||||
column_default: null,
|
||||
},
|
||||
{
|
||||
table_name: 'posts',
|
||||
column_name: 'user_id',
|
||||
data_type: 'integer',
|
||||
is_nullable: 'NO',
|
||||
column_default: null,
|
||||
},
|
||||
]
|
||||
|
||||
export const mockQueryResult = [
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
|
||||
]
|
||||
|
||||
export const mockInsertResult = [
|
||||
{ id: 3, name: 'New User', email: 'new@example.com' },
|
||||
]
|
||||
|
||||
export const validSelectQuery = 'SELECT * FROM users WHERE id = 1'
|
||||
export const validInsertQuery = 'INSERT INTO users (name, email) VALUES (\'Test\', \'test@example.com\')'
|
||||
export const validUpdateQuery = 'UPDATE users SET name = \'Updated\' WHERE id = 1'
|
||||
export const validDeleteQuery = 'DELETE FROM users WHERE id = 1'
|
||||
|
||||
export const dangerousDropQuery = 'DROP TABLE users'
|
||||
export const dangerousDeleteAllQuery = 'SELECT * FROM users; DELETE FROM users WHERE 1=1'
|
||||
export const maliciousInjectionQuery = 'SELECT * FROM users; DROP TABLE users; --'
|
||||
export const emptyQuery = ''
|
||||
export const whitespaceQuery = ' '
|
||||
38
use-cases/mcp-server/tests/fixtures/mcp.fixtures.ts
vendored
Normal file
38
use-cases/mcp-server/tests/fixtures/mcp.fixtures.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { McpResponse } from '../../src/types'
|
||||
|
||||
export const mockSuccessResponse: McpResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '**Success**\n\nOperation completed successfully',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const mockErrorResponse: McpResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '**Error**\n\nSomething went wrong',
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const mockQueryResponse: McpResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '**Query Results**\n```sql\nSELECT * FROM users\n```\n\n**Results:**\n```json\n[\n {\n "id": 1,\n "name": "John Doe"\n }\n]\n```\n\n**Rows returned:** 1',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const mockTableListResponse: McpResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '**Database Tables and Schema**\n\n[\n {\n "name": "users",\n "schema": "public",\n "columns": [\n {\n "name": "id",\n "type": "integer",\n "nullable": false,\n "default": "nextval(\'users_id_seq\'::regclass)"\n }\n ]\n }\n]\n\n**Total tables found:** 1\n\n**Note:** Use the `queryDatabase` tool to run SELECT queries, or `executeDatabase` tool for write operations (if you have write access).',
|
||||
},
|
||||
],
|
||||
}
|
||||
54
use-cases/mcp-server/tests/mocks/crypto.mock.ts
Normal file
54
use-cases/mcp-server/tests/mocks/crypto.mock.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock crypto.subtle for cookie signing
|
||||
export const mockCryptoSubtle = {
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
}
|
||||
|
||||
// Mock crypto.getRandomValues
|
||||
export const mockGetRandomValues = vi.fn()
|
||||
|
||||
export function setupCryptoMocks() {
|
||||
// Mock HMAC signing
|
||||
mockCryptoSubtle.sign.mockResolvedValue(new ArrayBuffer(32))
|
||||
|
||||
// Mock signature verification
|
||||
mockCryptoSubtle.verify.mockResolvedValue(true)
|
||||
|
||||
// Mock key import
|
||||
mockCryptoSubtle.importKey.mockResolvedValue({} as CryptoKey)
|
||||
|
||||
// Mock random values
|
||||
mockGetRandomValues.mockImplementation((array: Uint8Array) => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
return array
|
||||
})
|
||||
}
|
||||
|
||||
export function setupCryptoError() {
|
||||
mockCryptoSubtle.sign.mockRejectedValue(new Error('Crypto signing failed'))
|
||||
mockCryptoSubtle.verify.mockRejectedValue(new Error('Crypto verification failed'))
|
||||
}
|
||||
|
||||
export function resetCryptoMocks() {
|
||||
vi.clearAllMocks()
|
||||
setupCryptoMocks()
|
||||
}
|
||||
|
||||
// Apply mocks to global crypto object
|
||||
if (!global.crypto) {
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: {
|
||||
subtle: mockCryptoSubtle,
|
||||
getRandomValues: mockGetRandomValues,
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
} else {
|
||||
global.crypto.subtle = mockCryptoSubtle
|
||||
global.crypto.getRandomValues = mockGetRandomValues
|
||||
}
|
||||
57
use-cases/mcp-server/tests/mocks/database.mock.ts
Normal file
57
use-cases/mcp-server/tests/mocks/database.mock.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mockTableColumns, mockQueryResult } from '../fixtures/database.fixtures'
|
||||
|
||||
// Mock postgres function
|
||||
export const mockPostgresInstance = {
|
||||
unsafe: vi.fn(),
|
||||
end: vi.fn(),
|
||||
// Template literal query method
|
||||
'`SELECT * FROM users`': vi.fn(),
|
||||
}
|
||||
|
||||
// Mock the postgres module
|
||||
vi.mock('postgres', () => ({
|
||||
default: vi.fn(() => mockPostgresInstance),
|
||||
}))
|
||||
|
||||
// Mock database connection functions
|
||||
vi.mock('../../src/database/connection', () => ({
|
||||
getDb: vi.fn(() => mockPostgresInstance),
|
||||
closeDb: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock database utils
|
||||
vi.mock('../../src/database/utils', () => ({
|
||||
withDatabase: vi.fn(async (url: string, operation: any) => {
|
||||
return await operation(mockPostgresInstance)
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock setup functions
|
||||
export function setupDatabaseMocks() {
|
||||
mockPostgresInstance.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([])
|
||||
})
|
||||
}
|
||||
|
||||
export function setupDatabaseError() {
|
||||
mockPostgresInstance.unsafe.mockRejectedValue(new Error('Database connection failed'))
|
||||
}
|
||||
|
||||
export function setupDatabaseTimeout() {
|
||||
mockPostgresInstance.unsafe.mockRejectedValue(new Error('Connection timeout'))
|
||||
}
|
||||
|
||||
export function resetDatabaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
setupDatabaseMocks()
|
||||
}
|
||||
59
use-cases/mcp-server/tests/mocks/github.mock.ts
Normal file
59
use-cases/mcp-server/tests/mocks/github.mock.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mockGitHubUser, mockAccessToken } from '../fixtures/auth.fixtures'
|
||||
|
||||
// Mock Octokit
|
||||
export const mockOctokit = {
|
||||
rest: {
|
||||
users: {
|
||||
getAuthenticated: vi.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('octokit', () => ({
|
||||
Octokit: vi.fn(() => mockOctokit),
|
||||
}))
|
||||
|
||||
// Mock GitHub API responses
|
||||
export function setupGitHubMocks() {
|
||||
mockOctokit.rest.users.getAuthenticated.mockResolvedValue(mockGitHubUser)
|
||||
}
|
||||
|
||||
export function setupGitHubError() {
|
||||
mockOctokit.rest.users.getAuthenticated.mockRejectedValue(new Error('GitHub API error'))
|
||||
}
|
||||
|
||||
export function setupGitHubUnauthorized() {
|
||||
mockOctokit.rest.users.getAuthenticated.mockRejectedValue(new Error('Bad credentials'))
|
||||
}
|
||||
|
||||
export function resetGitHubMocks() {
|
||||
vi.clearAllMocks()
|
||||
setupGitHubMocks()
|
||||
}
|
||||
|
||||
// Mock fetch for GitHub OAuth token exchange
|
||||
export function setupGitHubTokenExchange() {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('github.com/login/oauth/access_token')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(`access_token=${mockAccessToken}&token_type=bearer&scope=read:user`),
|
||||
} as Response)
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected fetch call'))
|
||||
})
|
||||
}
|
||||
|
||||
export function setupGitHubTokenExchangeError() {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('github.com/login/oauth/access_token')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve('error=invalid_grant&error_description=Bad verification code.'),
|
||||
} as Response)
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected fetch call'))
|
||||
})
|
||||
}
|
||||
47
use-cases/mcp-server/tests/mocks/oauth.mock.ts
Normal file
47
use-cases/mcp-server/tests/mocks/oauth.mock.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mockAuthRequest, mockClientInfo } from '../fixtures/auth.fixtures'
|
||||
|
||||
// Mock OAuth provider
|
||||
export const mockOAuthProvider = {
|
||||
parseAuthRequest: vi.fn(),
|
||||
lookupClient: vi.fn(),
|
||||
completeAuthorization: vi.fn(),
|
||||
}
|
||||
|
||||
// Mock OAuth helpers
|
||||
export const mockOAuthHelpers = {
|
||||
...mockOAuthProvider,
|
||||
}
|
||||
|
||||
// Mock Cloudflare Workers OAuth Provider
|
||||
vi.mock('@cloudflare/workers-oauth-provider', () => ({
|
||||
default: vi.fn(() => ({
|
||||
fetch: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
export function setupOAuthMocks() {
|
||||
mockOAuthProvider.parseAuthRequest.mockResolvedValue(mockAuthRequest)
|
||||
mockOAuthProvider.lookupClient.mockResolvedValue(mockClientInfo)
|
||||
mockOAuthProvider.completeAuthorization.mockResolvedValue({
|
||||
redirectTo: 'http://localhost:3000/callback?code=success',
|
||||
})
|
||||
}
|
||||
|
||||
export function setupOAuthError() {
|
||||
mockOAuthProvider.parseAuthRequest.mockRejectedValue(new Error('Invalid OAuth request'))
|
||||
}
|
||||
|
||||
export function resetOAuthMocks() {
|
||||
vi.clearAllMocks()
|
||||
setupOAuthMocks()
|
||||
}
|
||||
|
||||
// Mock environment with OAuth provider
|
||||
export const mockEnv = {
|
||||
GITHUB_CLIENT_ID: 'test-client-id',
|
||||
GITHUB_CLIENT_SECRET: 'test-client-secret',
|
||||
COOKIE_ENCRYPTION_KEY: 'test-encryption-key',
|
||||
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
|
||||
OAUTH_PROVIDER: mockOAuthProvider,
|
||||
}
|
||||
20
use-cases/mcp-server/tests/setup.ts
Normal file
20
use-cases/mcp-server/tests/setup.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { beforeEach, vi } from 'vitest'
|
||||
|
||||
// Mock crypto API for Node.js environment
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: {
|
||||
subtle: {
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
},
|
||||
getRandomValues: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
135
use-cases/mcp-server/tests/unit/database/security.test.ts
Normal file
135
use-cases/mcp-server/tests/unit/database/security.test.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
})
|
||||
78
use-cases/mcp-server/tests/unit/database/utils.test.ts
Normal file
78
use-cases/mcp-server/tests/unit/database/utils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
257
use-cases/mcp-server/tests/unit/tools/database-tools.test.ts
Normal file
257
use-cases/mcp-server/tests/unit/tools/database-tools.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
155
use-cases/mcp-server/tests/unit/utils/response-helpers.test.ts
Normal file
155
use-cases/mcp-server/tests/unit/utils/response-helpers.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user