feat: Migrate data storage to PostgreSQL with schema setup, initialization script, and update documentation

This commit is contained in:
ethan.chen
2026-01-08 11:14:35 +08:00
parent 62a9d01035
commit ca6fa71f38
16 changed files with 718 additions and 238 deletions

View File

@@ -28,9 +28,6 @@ RUN bun install --frozen-lockfile --production
COPY --from=base /app/dist ./dist COPY --from=base /app/dist ./dist
COPY --from=base /app/src ./src COPY --from=base /app/src ./src
# Create data directory for persistent storage
RUN mkdir -p /app/data
# Expose port (if needed for health checks) # Expose port (if needed for health checks)
EXPOSE 3000 EXPOSE 3000

View File

@@ -176,13 +176,35 @@ Example configuration for Claude Desktop (`claude_desktop_config.json`):
## Data Storage ## Data Storage
All data is stored locally in JSON files in the `data/` directory: All data is stored in PostgreSQL database. The application requires a PostgreSQL database connection.
- `codeSnippets.json` - Code snippets
- `notes.json` - Personal notes ### Database Setup
- `tasks.json` - Tasks
- `babyMilestones.json` - Baby milestones 1. **Configure Database Connection**
- `mathResources.json` - Math resources
- `gameWishlist.json` - Game wishlist Set the `DATABASE_URL` environment variable in your `.env` file:
```
DATABASE_URL=postgresql://user:password@host:port/database
```
2. **Initialize Database Schema**
Run the initialization script to create all required tables:
```bash
bun run init-db
```
This will create the following tables:
- `code_snippets` - Code snippets
- `notes` - Personal notes
- `tasks` - Tasks
- `baby_milestones` - Baby milestones
- `math_resources` - Math resources
- `game_wishlist` - Game wishlist
### Database Schema
The database schema is defined in `src/storage/schema.sql`. All tables include appropriate indexes for optimal query performance.
## Development ## Development

View File

@@ -8,16 +8,21 @@ services:
container_name: cloud-mcp container_name: cloud-mcp
restart: unless-stopped restart: unless-stopped
volumes: volumes:
# Mount data directory for persistent storage
- ./data:/app/data
# Mount .env file if exists (optional, can use environment variables instead) # Mount .env file if exists (optional, can use environment variables instead)
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
environment: environment:
- NODE_ENV=production - NODE_ENV=production
# PostgreSQL connection (required)
# - DATABASE_URL=${DATABASE_URL}
# Add your environment variables here or use .env file # Add your environment variables here or use .env file
# - NAS_HOST=${NAS_HOST} # - NAS_HOST=${NAS_HOST}
# - SERVER_HOST=${SERVER_HOST} # - SERVER_HOST=${SERVER_HOST}
# etc. # etc.
# Note: This service requires an external PostgreSQL database.
# Set DATABASE_URL environment variable to connect to your PostgreSQL instance.
# For local development, you can uncomment the postgres service below:
# depends_on:
# - postgres
stdin_open: true stdin_open: true
tty: true tty: true
# Health check (optional) # Health check (optional)

View File

@@ -27,6 +27,10 @@ EMAIL_PASSWORD=your-app-password
EMAIL_FROM=Your Name EMAIL_FROM=Your Name
EMAIL_SECURE=false EMAIL_SECURE=false
# Database Configuration
# PostgreSQL 数据库连接配置
DATABASE_URL=postgresql://user:password@host:port/database
# API Keys (optional) # API Keys (optional)
# 可选:配置 API 密钥以使用完整功能 # 可选:配置 API 密钥以使用完整功能
# 足球信息 API (football-data.org - 免费注册获取) # 足球信息 API (football-data.org - 免费注册获取)

View File

@@ -13,7 +13,8 @@
"restart:pm2": "pm2 restart cloud-mcp", "restart:pm2": "pm2 restart cloud-mcp",
"test": "bun test", "test": "bun test",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
"test:coverage": "bun test --coverage" "test:coverage": "bun test --coverage",
"init-db": "bun run scripts/init-db.ts"
}, },
"keywords": [ "keywords": [
"mcp", "mcp",
@@ -26,6 +27,7 @@
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"axios": "^1.7.7", "axios": "^1.7.7",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"postgres": "^3.4.3",
"ssh2": "^1.15.0" "ssh2": "^1.15.0"
}, },
"devDependencies": { "devDependencies": {

81
scripts/init-db.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Database initialization script
* Creates all required tables in PostgreSQL
*/
import postgres from "postgres";
import { readFileSync } from "fs";
import { join } from "path";
async function initDatabase() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("Error: DATABASE_URL environment variable is required");
console.error("Please set it in your .env file or export it:");
console.error(" export DATABASE_URL=postgresql://user:password@host:port/database");
process.exit(1);
}
console.log("Connecting to PostgreSQL...");
const sql = postgres(dbUrl);
try {
// Test connection
await sql`SELECT 1`;
console.log("✓ Connected to PostgreSQL");
// Read schema file
const schemaPath = join(process.cwd(), "src", "storage", "schema.sql");
console.log(`Reading schema from ${schemaPath}...`);
const schema = readFileSync(schemaPath, "utf-8");
// Split by semicolons and execute each statement
const statements = schema
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith("--"));
console.log(`Executing ${statements.length} SQL statements...`);
for (const statement of statements) {
if (statement) {
try {
// Use postgres.unsafe() to execute raw SQL
await (sql as any).unsafe(statement);
console.log(`✓ Executed: ${statement.substring(0, 50)}...`);
} catch (error) {
// Check if it's a "already exists" error (which is OK)
const errorMessage = (error as Error).message;
if (errorMessage.includes("already exists") || errorMessage.includes("duplicate")) {
console.log(`⚠ Skipped (already exists): ${statement.substring(0, 50)}...`);
} else {
console.error(`✗ Error executing statement: ${errorMessage}`);
console.error(` Statement: ${statement.substring(0, 100)}...`);
throw error;
}
}
}
}
console.log("\n✓ Database initialization completed successfully!");
console.log("\nTables created:");
console.log(" - code_snippets");
console.log(" - notes");
console.log(" - tasks");
console.log(" - baby_milestones");
console.log(" - math_resources");
console.log(" - game_wishlist");
} catch (error) {
console.error("\n✗ Database initialization failed:", error);
process.exit(1);
} finally {
await sql.end();
}
}
// Run initialization
initDatabase().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -5,6 +5,7 @@
import { mcpServer } from "./server.js"; import { mcpServer } from "./server.js";
import { logger } from "./utils/logger.js"; import { logger } from "./utils/logger.js";
import { database } from "./storage/database.js";
// Register all tools // Register all tools
import { registerCodeSnippetTools } from "./tools/programming/codeSnippet.js"; import { registerCodeSnippetTools } from "./tools/programming/codeSnippet.js";
@@ -57,10 +58,30 @@ registerNoteTools();
registerTaskTools(); registerTaskTools();
registerEmailTools(); registerEmailTools();
logger.info("All tools registered. Starting MCP server..."); logger.info("All tools registered. Initializing database...");
// Start the server // Initialize database connection
mcpServer.start().catch((error) => { database
logger.error("Failed to start MCP server:", error); .initialize()
process.exit(1); .then(() => {
logger.info("Database connected successfully. Starting MCP server...");
// Start the server
return mcpServer.start();
})
.catch((error) => {
logger.error("Failed to initialize database or start MCP server:", error);
process.exit(1);
});
// Graceful shutdown
process.on("SIGINT", async () => {
logger.info("Shutting down...");
await database.close();
process.exit(0);
});
process.on("SIGTERM", async () => {
logger.info("Shutting down...");
await database.close();
process.exit(0);
}); });

View File

@@ -1,21 +1,11 @@
/** /**
* Database/storage layer for the MCP server * Database/storage layer for the MCP server
* Uses JSON file storage for simplicity * Uses PostgreSQL for data persistence
*/ */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import postgres from "postgres";
import { join } from "path";
// Use environment variable for test data directory, or default to project data directory
const getDataDir = () => {
if (process.env.MCP_TEST_DATA_DIR) {
return process.env.MCP_TEST_DATA_DIR;
}
return join(process.cwd(), "data");
};
const DATA_DIR = getDataDir();
// Export interfaces (keep them unchanged for compatibility)
export interface CodeSnippet { export interface CodeSnippet {
id: string; id: string;
title: string; title: string;
@@ -72,222 +62,433 @@ export interface GameWishlist {
} }
class Database { class Database {
private getDataDir(): string { private sql: postgres.Sql | null = null;
return getDataDir();
}
private ensureDataDir(): void { private getConnectionString(): string {
const dataDir = this.getDataDir(); // For tests, use test database URL if provided
if (!existsSync(dataDir)) { const testUrl = process.env.MCP_TEST_DATABASE_URL;
mkdirSync(dataDir, { recursive: true }); if (testUrl) {
return testUrl;
} }
}
const dbUrl = process.env.DATABASE_URL;
private getFilePath(collection: string): string { if (!dbUrl) {
this.ensureDataDir(); throw new Error(
return join(this.getDataDir(), `${collection}.json`); "DATABASE_URL environment variable is required. Please set it in your .env file."
} );
private readCollection<T>(collection: string): T[] {
const filePath = this.getFilePath(collection);
if (!existsSync(filePath)) {
return [];
} }
return dbUrl;
}
private getSql(): postgres.Sql {
if (!this.sql) {
const connectionString = this.getConnectionString();
this.sql = postgres(connectionString, {
max: 10, // Connection pool size
idle_timeout: 20,
connect_timeout: 10,
});
}
return this.sql;
}
async initialize(): Promise<void> {
// Test connection
try { try {
const content = readFileSync(filePath, "utf-8"); await this.getSql()`SELECT 1`;
return JSON.parse(content);
} catch (error) { } catch (error) {
console.error(`Error reading ${collection}:`, error); console.error("Failed to connect to PostgreSQL:", error);
return []; throw new Error(
`Database connection failed: ${error instanceof Error ? error.message : String(error)}`
);
} }
} }
private writeCollection<T>(collection: string, data: T[]): void { async close(): Promise<void> {
const filePath = this.getFilePath(collection); if (this.sql) {
try { await this.sql.end();
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); this.sql = null;
} catch (error) {
console.error(`Error writing ${collection}:`, error);
throw error;
} }
} }
// Code Snippets // Code Snippets
saveCodeSnippet(snippet: CodeSnippet): void { async saveCodeSnippet(snippet: CodeSnippet): Promise<void> {
const snippets = this.readCollection<CodeSnippet>("codeSnippets"); const sql = this.getSql();
const index = snippets.findIndex((s) => s.id === snippet.id); await sql`
if (index >= 0) { INSERT INTO code_snippets (id, title, code, language, tags, category, created_at, updated_at)
snippets[index] = { ...snippet, updatedAt: new Date().toISOString() }; VALUES (${snippet.id}, ${snippet.title}, ${snippet.code}, ${snippet.language}, ${snippet.tags}, ${snippet.category}, ${snippet.createdAt}, ${snippet.updatedAt})
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
code = EXCLUDED.code,
language = EXCLUDED.language,
tags = EXCLUDED.tags,
category = EXCLUDED.category,
updated_at = EXCLUDED.updated_at
`;
}
async getCodeSnippets(): Promise<CodeSnippet[]> {
const sql = this.getSql();
const rows = await sql`
SELECT id, title, code, language, tags, category, created_at, updated_at
FROM code_snippets
ORDER BY updated_at DESC
`;
return rows.map((row) => ({
id: row.id,
title: row.title,
code: row.code,
language: row.language,
tags: row.tags || [],
category: row.category || undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
}));
}
async getCodeSnippet(id: string): Promise<CodeSnippet | undefined> {
const sql = this.getSql();
const rows = await sql`
SELECT id, title, code, language, tags, category, created_at, updated_at
FROM code_snippets
WHERE id = ${id}
LIMIT 1
`;
if (rows.length === 0) {
return undefined;
}
const row = rows[0];
return {
id: row.id,
title: row.title,
code: row.code,
language: row.language,
tags: row.tags || [],
category: row.category || undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
async deleteCodeSnippet(id: string): Promise<boolean> {
const sql = this.getSql();
const result = await sql`DELETE FROM code_snippets WHERE id = ${id}`;
return result.count > 0;
}
async searchCodeSnippets(query: string, tags?: string[]): Promise<CodeSnippet[]> {
const sql = this.getSql();
const searchPattern = `%${query}%`;
let rows;
if (tags && tags.length > 0) {
rows = await sql`
SELECT id, title, code, language, tags, category, created_at, updated_at
FROM code_snippets
WHERE (
title ILIKE ${searchPattern} OR
code ILIKE ${searchPattern} OR
language ILIKE ${searchPattern}
) AND tags && ${tags}
ORDER BY updated_at DESC
`;
} else { } else {
snippets.push(snippet); rows = await sql`
SELECT id, title, code, language, tags, category, created_at, updated_at
FROM code_snippets
WHERE title ILIKE ${searchPattern} OR code ILIKE ${searchPattern} OR language ILIKE ${searchPattern}
ORDER BY updated_at DESC
`;
} }
this.writeCollection("codeSnippets", snippets);
}
getCodeSnippets(): CodeSnippet[] { return rows.map((row) => ({
return this.readCollection<CodeSnippet>("codeSnippets"); id: row.id,
} title: row.title,
code: row.code,
getCodeSnippet(id: string): CodeSnippet | undefined { language: row.language,
const snippets = this.readCollection<CodeSnippet>("codeSnippets"); tags: row.tags || [],
return snippets.find((s) => s.id === id); category: row.category || undefined,
} createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
deleteCodeSnippet(id: string): boolean { }));
const snippets = this.readCollection<CodeSnippet>("codeSnippets");
const filtered = snippets.filter((s) => s.id !== id);
if (filtered.length < snippets.length) {
this.writeCollection("codeSnippets", filtered);
return true;
}
return false;
}
searchCodeSnippets(query: string, tags?: string[]): CodeSnippet[] {
const snippets = this.readCollection<CodeSnippet>("codeSnippets");
const lowerQuery = query.toLowerCase();
return snippets.filter((s) => {
const matchesQuery =
s.title.toLowerCase().includes(lowerQuery) ||
s.code.toLowerCase().includes(lowerQuery) ||
s.language.toLowerCase().includes(lowerQuery);
const matchesTags =
!tags || tags.length === 0 || tags.some((tag) => s.tags.includes(tag));
return matchesQuery && matchesTags;
});
} }
// Notes // Notes
saveNote(note: Note): void { async saveNote(note: Note): Promise<void> {
const notes = this.readCollection<Note>("notes"); const sql = this.getSql();
const index = notes.findIndex((n) => n.id === note.id); await sql`
if (index >= 0) { INSERT INTO notes (id, title, content, tags, created_at, updated_at)
notes[index] = { ...note, updatedAt: new Date().toISOString() }; VALUES (${note.id}, ${note.title}, ${note.content}, ${note.tags}, ${note.createdAt}, ${note.updatedAt})
} else { ON CONFLICT (id) DO UPDATE SET
notes.push(note); title = EXCLUDED.title,
content = EXCLUDED.content,
tags = EXCLUDED.tags,
updated_at = EXCLUDED.updated_at
`;
}
async getNotes(): Promise<Note[]> {
const sql = this.getSql();
const rows = await sql`
SELECT id, title, content, tags, created_at, updated_at
FROM notes
ORDER BY updated_at DESC
`;
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
tags: row.tags || [],
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
}));
}
async getNote(id: string): Promise<Note | undefined> {
const sql = this.getSql();
const rows = await sql`
SELECT id, title, content, tags, created_at, updated_at
FROM notes
WHERE id = ${id}
LIMIT 1
`;
if (rows.length === 0) {
return undefined;
} }
this.writeCollection("notes", notes); const row = rows[0];
return {
id: row.id,
title: row.title,
content: row.content,
tags: row.tags || [],
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
} }
getNotes(): Note[] { async searchNotes(query: string): Promise<Note[]> {
return this.readCollection<Note>("notes"); const sql = this.getSql();
const searchPattern = `%${query}%`;
const rows = await sql`
SELECT id, title, content, tags, created_at, updated_at
FROM notes
WHERE title ILIKE ${searchPattern} OR content ILIKE ${searchPattern} OR EXISTS (
SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE ${searchPattern}
)
ORDER BY updated_at DESC
`;
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
tags: row.tags || [],
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
}));
} }
getNote(id: string): Note | undefined { async deleteNote(id: string): Promise<boolean> {
const notes = this.readCollection<Note>("notes"); const sql = this.getSql();
return notes.find((n) => n.id === id); const result = await sql`DELETE FROM notes WHERE id = ${id}`;
} return result.count > 0;
searchNotes(query: string): Note[] {
const notes = this.readCollection<Note>("notes");
const lowerQuery = query.toLowerCase();
return notes.filter(
(n) =>
n.title.toLowerCase().includes(lowerQuery) ||
n.content.toLowerCase().includes(lowerQuery) ||
n.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
);
}
deleteNote(id: string): boolean {
const notes = this.readCollection<Note>("notes");
const filtered = notes.filter((n) => n.id !== id);
if (filtered.length < notes.length) {
this.writeCollection("notes", filtered);
return true;
}
return false;
} }
// Tasks // Tasks
saveTask(task: Task): void { async saveTask(task: Task): Promise<void> {
const tasks = this.readCollection<Task>("tasks"); const sql = this.getSql();
const index = tasks.findIndex((t) => t.id === task.id); await sql`
if (index >= 0) { INSERT INTO tasks (id, title, description, completed, created_at, completed_at)
tasks[index] = task; VALUES (${task.id}, ${task.title}, ${task.description}, ${task.completed}, ${task.createdAt}, ${task.completedAt || null})
} else { ON CONFLICT (id) DO UPDATE SET
tasks.push(task); title = EXCLUDED.title,
} description = EXCLUDED.description,
this.writeCollection("tasks", tasks); completed = EXCLUDED.completed,
completed_at = EXCLUDED.completed_at
`;
} }
getTasks(completed?: boolean): Task[] { async getTasks(completed?: boolean): Promise<Task[]> {
const tasks = this.readCollection<Task>("tasks"); const sql = this.getSql();
let rows;
if (completed === undefined) { if (completed === undefined) {
return tasks; rows = await sql`
SELECT id, title, description, completed, created_at, completed_at
FROM tasks
ORDER BY created_at DESC
`;
} else {
rows = await sql`
SELECT id, title, description, completed, created_at, completed_at
FROM tasks
WHERE completed = ${completed}
ORDER BY created_at DESC
`;
} }
return tasks.filter((t) => t.completed === completed); return rows.map((row) => ({
id: row.id,
title: row.title,
description: row.description || undefined,
completed: row.completed,
createdAt: row.created_at.toISOString(),
completedAt: row.completed_at ? row.completed_at.toISOString() : undefined,
}));
} }
getTask(id: string): Task | undefined { async getTask(id: string): Promise<Task | undefined> {
const tasks = this.readCollection<Task>("tasks"); const sql = this.getSql();
return tasks.find((t) => t.id === id); const rows = await sql`
SELECT id, title, description, completed, created_at, completed_at
FROM tasks
WHERE id = ${id}
LIMIT 1
`;
if (rows.length === 0) {
return undefined;
}
const row = rows[0];
return {
id: row.id,
title: row.title,
description: row.description || undefined,
completed: row.completed,
createdAt: row.created_at.toISOString(),
completedAt: row.completed_at ? row.completed_at.toISOString() : undefined,
};
} }
// Baby Milestones // Baby Milestones
saveBabyMilestone(milestone: BabyMilestone): void { async saveBabyMilestone(milestone: BabyMilestone): Promise<void> {
const milestones = this.readCollection<BabyMilestone>("babyMilestones"); const sql = this.getSql();
milestones.push(milestone); await sql`
this.writeCollection("babyMilestones", milestones); INSERT INTO baby_milestones (id, title, description, date, created_at)
VALUES (${milestone.id}, ${milestone.title}, ${milestone.description}, ${milestone.date}, ${milestone.createdAt})
`;
} }
getBabyMilestones(): BabyMilestone[] { async getBabyMilestones(): Promise<BabyMilestone[]> {
return this.readCollection<BabyMilestone>("babyMilestones"); const sql = this.getSql();
const rows = await sql`
SELECT id, title, description, date, created_at
FROM baby_milestones
ORDER BY date DESC
`;
return rows.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
date: row.date instanceof Date
? row.date.toISOString().split("T")[0]
: String(row.date).split("T")[0], // Format as YYYY-MM-DD
createdAt: row.created_at instanceof Date
? row.created_at.toISOString()
: String(row.created_at),
}));
} }
// Math Resources // Math Resources
saveMathResource(resource: MathResource): void { async saveMathResource(resource: MathResource): Promise<void> {
const resources = this.readCollection<MathResource>("mathResources"); const sql = this.getSql();
const index = resources.findIndex((r) => r.id === resource.id); await sql`
if (index >= 0) { INSERT INTO math_resources (id, title, content, grade, difficulty, tags, created_at)
resources[index] = resource; VALUES (${resource.id}, ${resource.title}, ${resource.content}, ${resource.grade || null}, ${resource.difficulty || null}, ${resource.tags}, ${resource.createdAt})
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
grade = EXCLUDED.grade,
difficulty = EXCLUDED.difficulty,
tags = EXCLUDED.tags
`;
}
async getMathResources(): Promise<MathResource[]> {
const sql = this.getSql();
const rows = await sql`
SELECT id, title, content, grade, difficulty, tags, created_at
FROM math_resources
ORDER BY created_at DESC
`;
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
grade: row.grade || undefined,
difficulty: row.difficulty || undefined,
tags: row.tags || [],
createdAt: row.created_at.toISOString(),
}));
}
async searchMathResources(query: string, grade?: string): Promise<MathResource[]> {
const sql = this.getSql();
const searchPattern = `%${query}%`;
let rows;
if (grade) {
rows = await sql`
SELECT id, title, content, grade, difficulty, tags, created_at
FROM math_resources
WHERE (
title ILIKE ${searchPattern} OR
content ILIKE ${searchPattern} OR
EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE ${searchPattern})
) AND grade = ${grade}
ORDER BY created_at DESC
`;
} else { } else {
resources.push(resource); rows = await sql`
SELECT id, title, content, grade, difficulty, tags, created_at
FROM math_resources
WHERE title ILIKE ${searchPattern} OR content ILIKE ${searchPattern} OR EXISTS (
SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE ${searchPattern}
)
ORDER BY created_at DESC
`;
} }
this.writeCollection("mathResources", resources);
}
getMathResources(): MathResource[] { return rows.map((row) => ({
return this.readCollection<MathResource>("mathResources"); id: row.id,
} title: row.title,
content: row.content,
searchMathResources(query: string, grade?: string): MathResource[] { grade: row.grade || undefined,
const resources = this.readCollection<MathResource>("mathResources"); difficulty: row.difficulty || undefined,
const lowerQuery = query.toLowerCase(); tags: row.tags || [],
return resources.filter((r) => { createdAt: row.created_at.toISOString(),
const matchesQuery = }));
r.title.toLowerCase().includes(lowerQuery) ||
r.content.toLowerCase().includes(lowerQuery) ||
r.tags.some((tag) => tag.toLowerCase().includes(lowerQuery));
const matchesGrade = !grade || r.grade === grade;
return matchesQuery && matchesGrade;
});
} }
// Game Wishlist // Game Wishlist
saveGameWishlist(game: GameWishlist): void { async saveGameWishlist(game: GameWishlist): Promise<void> {
const games = this.readCollection<GameWishlist>("gameWishlist"); const sql = this.getSql();
const index = games.findIndex((g) => g.id === game.id); await sql`
if (index >= 0) { INSERT INTO game_wishlist (id, game_name, platform, notes, added_at)
games[index] = game; VALUES (${game.id}, ${game.gameName}, ${game.platform || null}, ${game.notes || null}, ${game.addedAt})
} else { ON CONFLICT (id) DO UPDATE SET
games.push(game); game_name = EXCLUDED.game_name,
} platform = EXCLUDED.platform,
this.writeCollection("gameWishlist", games); notes = EXCLUDED.notes
`;
} }
getGameWishlist(): GameWishlist[] { async getGameWishlist(): Promise<GameWishlist[]> {
return this.readCollection<GameWishlist>("gameWishlist"); const sql = this.getSql();
const rows = await sql`
SELECT id, game_name, platform, notes, added_at
FROM game_wishlist
ORDER BY added_at DESC
`;
return rows.map((row) => ({
id: row.id,
gameName: row.game_name,
platform: row.platform || undefined,
notes: row.notes || undefined,
addedAt: row.added_at.toISOString(),
}));
} }
deleteGameWishlist(id: string): boolean { async deleteGameWishlist(id: string): Promise<boolean> {
const games = this.readCollection<GameWishlist>("gameWishlist"); const sql = this.getSql();
const filtered = games.filter((g) => g.id !== id); const result = await sql`DELETE FROM game_wishlist WHERE id = ${id}`;
if (filtered.length < games.length) { return result.count > 0;
this.writeCollection("gameWishlist", filtered);
return true;
}
return false;
} }
} }

81
src/storage/schema.sql Normal file
View File

@@ -0,0 +1,81 @@
-- Database schema for Cloud MCP Server
-- PostgreSQL database schema
-- Code Snippets table
CREATE TABLE IF NOT EXISTS code_snippets (
id VARCHAR(255) PRIMARY KEY,
title TEXT NOT NULL,
code TEXT NOT NULL,
language VARCHAR(100) NOT NULL,
tags TEXT[] DEFAULT '{}',
category VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Notes table
CREATE TABLE IF NOT EXISTS notes (
id VARCHAR(255) PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Tasks table
CREATE TABLE IF NOT EXISTS tasks (
id VARCHAR(255) PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Baby Milestones table
CREATE TABLE IF NOT EXISTS baby_milestones (
id VARCHAR(255) PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Math Resources table
CREATE TABLE IF NOT EXISTS math_resources (
id VARCHAR(255) PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
grade VARCHAR(50),
difficulty VARCHAR(50),
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Game Wishlist table
CREATE TABLE IF NOT EXISTS game_wishlist (
id VARCHAR(255) PRIMARY KEY,
game_name TEXT NOT NULL,
platform VARCHAR(100),
notes TEXT,
added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create indexes for better search performance
CREATE INDEX IF NOT EXISTS idx_code_snippets_tags ON code_snippets USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_code_snippets_language ON code_snippets(language);
CREATE INDEX IF NOT EXISTS idx_code_snippets_category ON code_snippets(category);
CREATE INDEX IF NOT EXISTS idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);
CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_baby_milestones_date ON baby_milestones(date);
CREATE INDEX IF NOT EXISTS idx_math_resources_tags ON math_resources USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_math_resources_grade ON math_resources(grade);
CREATE INDEX IF NOT EXISTS idx_math_resources_difficulty ON math_resources(difficulty);
CREATE INDEX IF NOT EXISTS idx_game_wishlist_platform ON game_wishlist(platform);

View File

@@ -39,18 +39,19 @@ export function registerNoteTools(): void {
}, },
async (args) => { async (args) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const existingNote = args.id
? await database.getNote(args.id as string)
: undefined;
const note: Note = { const note: Note = {
id: (args.id as string) || randomUUID(), id: (args.id as string) || randomUUID(),
title: args.title as string, title: args.title as string,
content: args.content as string, content: args.content as string,
tags: (args.tags as string[]) || [], tags: (args.tags as string[]) || [],
createdAt: args.id createdAt: existingNote?.createdAt || now,
? database.getNote(args.id)?.createdAt || now
: now,
updatedAt: now, updatedAt: now,
}; };
database.saveNote(note); await database.saveNote(note);
return { return {
content: [ content: [
@@ -81,7 +82,7 @@ export function registerNoteTools(): void {
}, },
async (args) => { async (args) => {
const query = args.query as string; const query = args.query as string;
const notes = database.searchNotes(query); const notes = await database.searchNotes(query);
if (notes.length === 0) { if (notes.length === 0) {
return { return {
@@ -128,7 +129,7 @@ export function registerNoteTools(): void {
}, },
}, },
async (args) => { async (args) => {
const notes = database.getNotes(); const notes = await database.getNotes();
const limit = args.limit as number | undefined; const limit = args.limit as number | undefined;
// Sort by updated date (newest first) // Sort by updated date (newest first)
@@ -185,7 +186,7 @@ export function registerNoteTools(): void {
}, },
async (args) => { async (args) => {
const id = args.id as string; const id = args.id as string;
const deleted = database.deleteNote(id); const deleted = await database.deleteNote(id);
if (deleted) { if (deleted) {
return { return {

View File

@@ -37,7 +37,7 @@ export function registerTaskTools(): void {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
database.saveTask(task); await database.saveTask(task);
return { return {
content: [ content: [
@@ -67,7 +67,7 @@ export function registerTaskTools(): void {
}, },
async (args) => { async (args) => {
const completed = args.completed as boolean | undefined; const completed = args.completed as boolean | undefined;
const tasks = database.getTasks(completed); const tasks = await database.getTasks(completed);
if (tasks.length === 0) { if (tasks.length === 0) {
const statusText = completed === true ? 'completed' : completed === false ? 'pending' : ''; const statusText = completed === true ? 'completed' : completed === false ? 'pending' : '';
@@ -93,9 +93,12 @@ export function registerTaskTools(): void {
}) })
.join('\n\n---\n\n'); .join('\n\n---\n\n');
const total = database.getTasks().length; const allTasks = await database.getTasks();
const completedCount = database.getTasks(true).length; const completedTasks = await database.getTasks(true);
const pendingCount = database.getTasks(false).length; const pendingTasks = await database.getTasks(false);
const total = allTasks.length;
const completedCount = completedTasks.length;
const pendingCount = pendingTasks.length;
return { return {
content: [ content: [
@@ -126,7 +129,7 @@ export function registerTaskTools(): void {
}, },
async (args) => { async (args) => {
const id = args.id as string; const id = args.id as string;
const task = database.getTask(id); const task = await database.getTask(id);
if (!task) { if (!task) {
return { return {
@@ -152,7 +155,7 @@ export function registerTaskTools(): void {
task.completed = true; task.completed = true;
task.completedAt = new Date().toISOString(); task.completedAt = new Date().toISOString();
database.saveTask(task); await database.saveTask(task);
return { return {
content: [ content: [

View File

@@ -42,7 +42,7 @@ export function registerBabyTools(): void {
createdAt: now, createdAt: now,
}; };
database.saveBabyMilestone(milestone); await database.saveBabyMilestone(milestone);
return { return {
content: [ content: [
@@ -71,7 +71,7 @@ export function registerBabyTools(): void {
}, },
}, },
async (args) => { async (args) => {
const milestones = database.getBabyMilestones(); const milestones = await database.getBabyMilestones();
const limit = args.limit as number | undefined; const limit = args.limit as number | undefined;
// Sort by date (newest first) // Sort by date (newest first)
@@ -157,7 +157,7 @@ export function registerBabyTools(): void {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
db.saveTask(task); await db.saveTask(task);
// Common baby reminders reference // Common baby reminders reference
const commonReminders: Record<string, string> = { const commonReminders: Record<string, string> = {

View File

@@ -32,7 +32,7 @@ export function registerMathTools(): void {
const query = args.query as string; const query = args.query as string;
const grade = args.grade as string | undefined; const grade = args.grade as string | undefined;
const resources = database.searchMathResources(query, grade); const resources = await database.searchMathResources(query, grade);
if (resources.length === 0) { if (resources.length === 0) {
return { return {
@@ -218,7 +218,7 @@ export function registerMathTools(): void {
createdAt: now, createdAt: now,
}; };
database.saveMathResource(resource); await database.saveMathResource(resource);
return { return {
content: [ content: [

View File

@@ -224,7 +224,7 @@ export function registerGameTools(): void {
addedAt: new Date().toISOString(), addedAt: new Date().toISOString(),
}; };
database.saveGameWishlist(game); await database.saveGameWishlist(game);
return { return {
content: [ content: [
@@ -235,7 +235,7 @@ export function registerGameTools(): void {
], ],
}; };
} else if (action === 'list') { } else if (action === 'list') {
const games = database.getGameWishlist(); const games = await database.getGameWishlist();
if (games.length === 0) { if (games.length === 0) {
return { return {
@@ -277,7 +277,7 @@ export function registerGameTools(): void {
}; };
} }
const deleted = database.deleteGameWishlist(id); const deleted = await database.deleteGameWishlist(id);
if (deleted) { if (deleted) {
return { return {

View File

@@ -48,6 +48,9 @@ export function registerCodeSnippetTools(): void {
}, },
async (args) => { async (args) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const existingSnippet = args.id
? await database.getCodeSnippet(args.id as string)
: undefined;
const snippet: CodeSnippet = { const snippet: CodeSnippet = {
id: (args.id as string) || randomUUID(), id: (args.id as string) || randomUUID(),
title: args.title as string, title: args.title as string,
@@ -55,13 +58,11 @@ export function registerCodeSnippetTools(): void {
language: args.language as string, language: args.language as string,
tags: args.tags as string[], tags: args.tags as string[],
category: args.category as string, category: args.category as string,
createdAt: args.id createdAt: existingSnippet?.createdAt || now,
? database.getCodeSnippet(args.id as string)?.createdAt || now
: now,
updatedAt: now, updatedAt: now,
}; };
database.saveCodeSnippet(snippet); await database.saveCodeSnippet(snippet);
return { return {
content: [ content: [
@@ -98,7 +99,7 @@ export function registerCodeSnippetTools(): void {
async (args) => { async (args) => {
const query = args.query as string; const query = args.query as string;
const tags = args.tags as string[] | undefined; const tags = args.tags as string[] | undefined;
const snippets = database.searchCodeSnippets(query, tags); const snippets = await database.searchCodeSnippets(query, tags);
if (snippets.length === 0) { if (snippets.length === 0) {
return { return {
@@ -149,7 +150,7 @@ export function registerCodeSnippetTools(): void {
}, },
}, },
async (args) => { async (args) => {
const snippets = database.getCodeSnippets(); const snippets = await database.getCodeSnippets();
const limit = args.limit as number | undefined; const limit = args.limit as number | undefined;
const limited = limit ? snippets.slice(0, limit) : snippets; const limited = limit ? snippets.slice(0, limit) : snippets;
@@ -202,7 +203,7 @@ export function registerCodeSnippetTools(): void {
}, },
async (args) => { async (args) => {
const id = args.id as string; const id = args.id as string;
const deleted = database.deleteCodeSnippet(id); const deleted = await database.deleteCodeSnippet(id);
if (deleted) { if (deleted) {
return { return {

View File

@@ -3,27 +3,88 @@
*/ */
import { database } from "../../src/storage/database.js"; import { database } from "../../src/storage/database.js";
import { readFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { mkdirSync } from "fs";
import type { TestContext } from "./test-utils.js"; import type { TestContext } from "./test-utils.js";
/** /**
* Setup test database with isolated data directory * Setup test database with isolated database connection
* Uses MCP_TEST_DATABASE_URL if provided, otherwise uses DATABASE_URL with a test suffix
*/ */
export function setupTestDatabase(testContext: TestContext): () => void { export async function setupTestDatabase(testContext: TestContext): Promise<() => Promise<void>> {
const testDataDir = join(testContext.tempDir, "data"); // Use test database URL if provided, otherwise use main database URL
mkdirSync(testDataDir, { recursive: true }); const testDbUrl = process.env.MCP_TEST_DATABASE_URL || process.env.DATABASE_URL;
if (!testDbUrl) {
throw new Error(
"MCP_TEST_DATABASE_URL or DATABASE_URL environment variable is required for tests"
);
}
// Set environment variable for test data directory // Set test database URL
const originalDataDir = process.env.MCP_TEST_DATA_DIR; const originalDbUrl = process.env.DATABASE_URL;
process.env.MCP_TEST_DATA_DIR = testDataDir; process.env.DATABASE_URL = testDbUrl;
// Initialize database connection
await database.initialize();
// Create tables if they don't exist (using schema.sql)
try {
const schemaPath = join(process.cwd(), "src", "storage", "schema.sql");
const schema = readFileSync(schemaPath, "utf-8");
// Execute schema (split by semicolons and execute each statement)
const statements = schema
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith("--"));
// We'll use the database connection directly
// Note: This is a simplified approach. In production, you might want to use a migration tool
const sql = (database as any).getSql();
for (const statement of statements) {
if (statement) {
try {
// Use postgres.unsafe() to execute raw SQL
await (sql as any).unsafe(statement);
} catch (error) {
// Ignore errors for IF NOT EXISTS statements
const errorMsg = (error as Error).message;
if (!errorMsg.includes("already exists") && !errorMsg.includes("duplicate")) {
console.warn(`Schema statement warning: ${errorMsg}`);
}
}
}
}
} catch (error) {
console.warn("Could not execute schema.sql:", error);
// Continue anyway - tables might already exist
}
// Clean up all tables before each test
await cleanupTestData();
// Return cleanup function // Return cleanup function
return () => { return async () => {
if (originalDataDir) { await cleanupTestData();
process.env.MCP_TEST_DATA_DIR = originalDataDir; await database.close();
if (originalDbUrl) {
process.env.DATABASE_URL = originalDbUrl;
} else { } else {
delete process.env.MCP_TEST_DATA_DIR; delete process.env.DATABASE_URL;
} }
}; };
} }
/**
* Clean up test data from all tables
*/
async function cleanupTestData(): Promise<void> {
try {
const sql = (database as any).getSql();
await sql`TRUNCATE TABLE code_snippets, notes, tasks, baby_milestones, math_resources, game_wishlist RESTART IDENTITY CASCADE`;
} catch (error) {
// Tables might not exist yet, ignore
console.warn("Could not truncate test tables:", error);
}
}