perf: optimize MCP server (O(1) auth, compact JSON, trashedAt fix) + memento-note performance (lazy loading, server-side filtering, XSS fixes, dead code removal, security hardening)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s

MCP Server:
- Fix validateApiKey: O(1) direct lookup by shortId instead of loading all keys
- Add trashedAt:null filter to ALL note queries (trashed notes leaked in results)
- Compact JSON output (~40% smaller responses)
- Bounded session cache (Map with MAX_SESSIONS=500) to prevent memory leaks
- PostgreSQL connection pooling (connection_limit=10)
- Rewrite all 22 tool descriptions in clear English
- Fix /sse fallback to proper 307 redirect

memento-note Performance:
- loading=lazy on all note images
- Split notebooksRefreshKey from global refreshKey (note CRUD no longer re-fetches notebooks)
- Remove searchKey from trash count deps (no re-fetch on every keystroke)
- Server-side notebookId filter in getAllNotes() (biggest win)
- Skip collaborator fetch for non-shared notes (eliminates N+1 API calls)
- next/dynamic for MarkdownContent + 4 modals (code-split remark/rehype/KaTeX)
- Memoize DOMPurify sanitize with useMemo

Security:
- XSS: DOMPurify sanitize in note-card and note-history-modal
- Auth anti-enumeration: uniform errors in auth.ts
- CRON_SECRET mandatory on cron endpoints
- Rate limiting on login (5 attempts/min per email)
- Centralized API auth helpers (requireAuth/requireAdmin)
- randomize-labels changed GET→POST
- Removed debug endpoints (/api/debug/config, /api/debug/test-chat)

Cleanup:
- Removed dead code: .backup-keep, settings-backup, fix-*.js, debug-theme, fix-labels route
- Removed sensitive console.error in auth.ts
- Ollama fetchWithTimeout (30s/60s AbortController)
- i18n: full Arabic translation, Farsi missing keys
- Masonry drag-and-drop fix (localOrderMap, cross-section block)
- Sidebar notebook tooltip on truncation
This commit is contained in:
Antigravity
2026-05-03 18:41:38 +00:00
parent aee4b17306
commit 718f4c6900
58 changed files with 13249 additions and 7474 deletions

View File

@@ -96,52 +96,39 @@ export async function validateApiKey(prisma, rawKey) {
const keyHash = hashKey(rawKey);
// Check cache first
const cached = getCachedKey(keyHash);
if (cached) {
return cached;
}
// Optimized: Use startsWith to leverage index, then filter by hash
// This is much faster than loading all keys
const shortIdFromKey = rawKey.substring(7, 15); // Extract potential shortId from key
const entries = await prisma.systemConfig.findMany({
where: {
key: { startsWith: KEY_PREFIX },
},
take: 100, // Limit to prevent loading too many keys
});
const shortId = rawKey.substring(7, 15);
const configKey = `${KEY_PREFIX}${shortId}`;
for (const entry of entries) {
try {
const info = JSON.parse(entry.value);
if (info.keyHash === keyHash && info.active) {
// Update lastUsedAt (fire and forget - don't wait)
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: entry.key },
data: { value: JSON.stringify(info) },
}).catch(() => {}); // Ignore errors
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } });
if (!entry) return null;
const result = {
apiKeyId: info.shortId,
apiKeyName: info.name,
userId: info.userId,
userName: info.userName,
};
// Cache the result
setCachedKey(keyHash, result);
return result;
}
} catch {
// Invalid JSON, skip
}
try {
const info = JSON.parse(entry.value);
if (info.keyHash !== keyHash || !info.active) return null;
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: configKey },
data: { value: JSON.stringify(info) },
}).catch(() => {});
const result = {
apiKeyId: info.shortId,
apiKeyName: info.name,
userId: info.userId,
userName: info.userName,
};
setCachedKey(keyHash, result);
return result;
} catch {
return null;
}
return null;
}
/**

View File

@@ -1,30 +1,27 @@
#!/usr/bin/env node
/**
* Memento MCP Server - Streamable HTTP Transport (Optimized)
* Memento MCP Server - Streamable HTTP Transport (Fast)
*
* Performance improvements:
* - Prisma connection pooling
* - Request timeout handling
* - Response compression
* - Connection keep-alive
* - Request batching support
* - Compact JSON output
* - Bounded session cache
* - Proper keep-alive & timeouts
* - O(1) API key validation
*
* Environment variables:
* PORT - Server port (default: 3001)
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
* USER_ID - Optional user ID to filter data
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
* MCP_REQUIRE_AUTH - Set to 'true' to require x-api-key or x-user-id header
* MCP_API_KEY - Static API key for authentication (when MCP_REQUIRE_AUTH=true)
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
* MCP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)
* Environment:
* PORT Server port (default: 3001)
* DATABASE_URL Prisma database URL
* USER_ID Optional user ID filter
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
* MCP_REQUIRE_AUTH Set 'true' to require authentication
* MCP_API_KEY Static fallback API key
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
* MCP_REQUEST_TIMEOUT Timeout in ms (default: 30000)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { PrismaClient } from '@prisma/client';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
import express from 'express';
import cors from 'cors';
@@ -32,13 +29,12 @@ import { registerTools } from './tools.js';
import { validateApiKey, resolveUser } from './auth.js';
import { requestContext } from './request-context.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration
const PORT = process.env.PORT || 3001;
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
const REQUEST_TIMEOUT = parseInt(process.env.MCP_REQUEST_TIMEOUT, 10) || 30000;
const MAX_SESSIONS = 500;
const SESSION_TTL = 3600000;
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
@@ -48,51 +44,57 @@ function log(level, ...args) {
}
}
const app = express();
// Middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Database - requires DATABASE_URL environment variable
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('ERROR: DATABASE_URL environment variable is required');
console.error('ERROR: DATABASE_URL is required');
process.exit(1);
}
// OPTIMIZED: Prisma client with connection pooling
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
const prisma = new PrismaClient({
datasources: {
db: { url: databaseUrl },
},
datasources: { db: { url: databaseUrl } },
...(isPostgres ? { datasources: { db: { url: `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` } } } : {}),
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
// ── Auth Middleware ──────────────────────────────────────────────────────────
// ── Bounded Session Cache ───────────────────────────────────────────────────
const userSessions = {};
const SESSION_TIMEOUT = 3600000; // 1 hour
const sessions = new Map();
// Cleanup old sessions periodically
setInterval(() => {
function cleanupSessions() {
const now = Date.now();
let cleaned = 0;
for (const [key, session] of Object.entries(userSessions)) {
if (now - new Date(session.lastSeen).getTime() > SESSION_TIMEOUT) {
delete userSessions[key];
for (const [key, s] of sessions) {
if (now - s._lastSeen > SESSION_TTL) {
sessions.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
log('debug', `Cleaned up ${cleaned} expired sessions`);
if (cleaned > 0) log('debug', `Cleaned ${cleaned} sessions`);
}
function pruneIfFull() {
if (sessions.size < MAX_SESSIONS) return;
const entries = [...sessions.entries()].sort((a, b) => a[1]._lastSeen - b[1]._lastSeen);
for (let i = 0; i < Math.floor(MAX_SESSIONS / 4); i++) {
sessions.delete(entries[i][0]);
}
}, 600000); // Every 10 minutes
}
setInterval(cleanupSessions, 600000);
// ── Express ─────────────────────────────────────────────────────────────────
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// ── Auth Middleware ──────────────────────────────────────────────────────────
app.use(async (req, res, next) => {
// Dev mode: no auth required
if (process.env.MCP_REQUIRE_AUTH !== 'true') {
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
return next();
@@ -102,189 +104,128 @@ app.use(async (req, res, next) => {
const headerUserId = req.headers['x-user-id'];
if (!apiKey && !headerUserId) {
return res.status(401).json({
error: 'Authentication required',
message: 'Provide x-api-key header (recommended) or x-user-id header',
});
return res.status(401).json({ error: 'Authentication required', message: 'Provide x-api-key or x-user-id header' });
}
// ── Method 1: API Key (recommended) ──────────────────────────────
if (apiKey) {
const keyUser = await validateApiKey(prisma, apiKey);
if (keyUser) {
const sessionKey = `key:${keyUser.apiKeyId}`;
if (userSessions[sessionKey]) {
req.userSession = userSessions[sessionKey];
req.userSession.lastSeen = new Date().toISOString();
} else {
req.userSession = {
id: randomUUID(),
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
userId: keyUser.userId,
userName: keyUser.userName,
apiKeyId: keyUser.apiKeyId,
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
requestCount: 0,
isAuth: true,
authMethod: 'api-key',
};
userSessions[sessionKey] = req.userSession;
}
req.userSession = getOrCreateSession(`key:${keyUser.apiKeyId}`, {
name: `${keyUser.userName} (${keyUser.apiKeyName})`,
userId: keyUser.userId,
userName: keyUser.userName,
apiKeyId: keyUser.apiKeyId,
authMethod: 'api-key',
});
return next();
}
// Fallback: static env var key
if (process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY) {
const sessionKey = `static:${apiKey.substring(0, 8)}`;
if (userSessions[sessionKey]) {
req.userSession = userSessions[sessionKey];
req.userSession.lastSeen = new Date().toISOString();
} else {
req.userSession = {
id: randomUUID(),
name: 'Static API Key User',
userId: process.env.USER_ID || null,
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
requestCount: 0,
isAuth: true,
authMethod: 'static-key',
};
userSessions[sessionKey] = req.userSession;
}
req.userSession = getOrCreateSession(`static:${apiKey.substring(0, 8)}`, {
name: 'Static API Key User',
userId: process.env.USER_ID || null,
authMethod: 'static-key',
});
return next();
}
return res.status(401).json({ error: 'Invalid API key' });
}
// ── Method 2: User ID header (validate against DB) ──────────────
if (headerUserId) {
const user = await resolveUser(prisma, headerUserId);
if (!user) {
return res.status(401).json({ error: 'User not found', message: `No user matching: ${headerUserId}` });
}
const sessionKey = `user:${user.id}`;
if (userSessions[sessionKey]) {
req.userSession = userSessions[sessionKey];
req.userSession.lastSeen = new Date().toISOString();
} else {
req.userSession = {
id: randomUUID(),
name: user.name,
userId: user.id,
userName: user.name,
userEmail: user.email,
userRole: user.role,
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
requestCount: 0,
isAuth: true,
authMethod: 'user-id',
};
userSessions[sessionKey] = req.userSession;
return res.status(401).json({ error: 'User not found' });
}
req.userSession = getOrCreateSession(`user:${user.id}`, {
name: user.name,
userId: user.id,
userName: user.name,
userEmail: user.email,
userRole: user.role,
authMethod: 'user-id',
});
return next();
}
return res.status(401).json({ error: 'Authentication failed' });
});
// ── Request Logging ─────────────────────────────────────────────────────────
function getOrCreateSession(key, base) {
const existing = sessions.get(key);
if (existing) {
existing._lastSeen = Date.now();
existing.requestCount = (existing.requestCount || 0) + 1;
return existing;
}
pruneIfFull();
const s = {
id: randomUUID(),
...base,
connectedAt: new Date().toISOString(),
requestCount: 1,
isAuth: true,
_lastSeen: Date.now(),
};
sessions.set(key, s);
return s;
}
// ── Logging ─────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
const start = Date.now();
if (req.userSession) {
req.userSession.requestCount = (req.userSession.requestCount || 0) + 1;
}
res.on('finish', () => {
const duration = Date.now() - start;
const sessionId = req.userSession?.id?.substring(0, 8) || 'anon';
log('debug', `[${sessionId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
const ms = Date.now() - start;
const sid = req.userSession?.id?.substring(0, 8) || 'anon';
log('debug', `[${sid}] ${req.method} ${req.path} ${res.statusCode} ${ms}ms`);
});
next();
});
// ── Request Timeout Middleware ──────────────────────────────────────────────
// ── Timeout ─────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
req.setTimeout(REQUEST_TIMEOUT);
res.setTimeout(REQUEST_TIMEOUT, () => {
log('warn', `Request timeout: ${req.method} ${req.path}`);
res.status(504).json({ error: 'Gateway Timeout', message: 'Request took too long' });
if (!res.headersSent) {
res.status(504).json({ error: 'Gateway Timeout' });
}
});
next();
});
// ── MCP Server Setup ────────────────────────────────────────────────────────
// ── MCP Server ──────────────────────────────────────────────────────────────
const server = new Server(
{
name: 'memento-mcp-server',
version: '3.1.0',
},
{
capabilities: { tools: {} },
},
{ name: 'memento-mcp-server', version: '3.2.0' },
{ capabilities: { tools: {} } },
);
registerTools(server, prisma);
// ── HTTP Endpoints ──────────────────────────────────────────────────────────
// ── Routes ──────────────────────────────────────────────────────────────────
const transports = {};
// Health check
app.get('/', (req, res) => {
res.json({
name: 'Memento MCP Server',
version: '3.1.0',
version: '3.2.0',
status: 'running',
endpoints: { mcp: '/mcp', health: '/', sessions: '/sessions' },
auth: {
enabled: process.env.MCP_REQUIRE_AUTH === 'true',
method: 'x-api-key or x-user-id header',
},
tools: {
notes: 11,
notebooks: 6,
labels: 4,
reminders: 1,
total: 22,
},
performance: {
optimizations: [
'Connection pooling',
'Batch operations',
'API key caching',
'Request timeout handling',
'Parallel query execution',
],
},
auth: { enabled: process.env.MCP_REQUIRE_AUTH === 'true' },
tools: 22,
});
});
// Session status
app.get('/sessions', (req, res) => {
const sessions = Object.values(userSessions).map((s) => ({
id: s.id,
name: s.name,
connectedAt: s.connectedAt,
lastSeen: s.lastSeen,
requestCount: s.requestCount || 0,
const list = [...sessions.values()].map(s => ({
id: s.id, name: s.name, connectedAt: s.connectedAt,
requestCount: s.requestCount || 0, authMethod: s.authMethod,
}));
res.json({
activeUsers: sessions.length,
sessions,
uptime: process.uptime(),
});
res.json({ activeUsers: list.length, sessions: list, uptime: process.uptime() });
});
// MCP endpoint - Streamable HTTP
// MCP endpoint Streamable HTTP
app.all('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
@@ -295,15 +236,15 @@ app.all('/mcp', async (req, res) => {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
log('debug', `Session initialized: ${id}`);
log('debug', `Session init: ${id}`);
transports[id] = transport;
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
log('debug', `Session closed: ${sid}`);
if (sid) {
log('debug', `Session close: ${sid}`);
delete transports[sid];
}
};
@@ -311,79 +252,45 @@ app.all('/mcp', async (req, res) => {
await server.connect(transport);
}
// Pass authenticated userId to tool handlers via AsyncLocalStorage
const ctx = { userId: req.userSession?.userId || null };
await requestContext.run(ctx, async () => {
await transport.handleRequest(req, res, req.body);
});
});
// Legacy /sse redirect for backward compat
app.all('/sse', async (req, res) => {
// Redirect to /mcp
req.url = '/mcp';
return app._router.handle(req, res, () => {
res.status(404).json({ error: 'Not found' });
});
// Legacy /sse → /mcp redirect
app.all('/sse', (req, res) => {
res.redirect(307, '/mcp');
});
// ── Start Server ────────────────────────────────────────────────────────────
const transports = {};
// ── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
console.log(`
╔═══════════════════════════════════════════════════════════════
║ Memento MCP Server v3.1.0 (Streamable HTTP) - Optimized
╚═══════════════════════════════════════════════════════════════
╔═══════════════════════════════════════════════════════╗
║ Memento MCP Server v3.2.0 (Streamable HTTP)
╚═══════════════════════════════════════════════════════╝
Server: http://localhost:${PORT}
MCP: http://localhost:${PORT}/mcp
Health: http://localhost:${PORT}/
Sessions: http://localhost:${PORT}/sessions
Database: ${databaseUrl}
App URL: ${appBaseUrl}
User filter: per-request (from auth)
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev mode)'}
Timeout: ${REQUEST_TIMEOUT}ms
Performance Optimizations:
✅ Connection pooling
✅ Batch operations
✅ API key caching (60s TTL)
✅ Parallel query execution
✅ Request timeout handling
✅ Session cleanup
Tools (22 total):
Notes (11):
create_note, get_notes, get_note, update_note, delete_note,
search_notes, move_note, toggle_pin, toggle_archive,
export_notes, import_notes
Notebooks (6):
create_notebook, get_notebooks, get_notebook, update_notebook,
delete_notebook, reorder_notebooks
Labels (4):
create_label, get_labels, update_label, delete_label
Reminders (1):
get_due_reminders
N8N config: SSE endpoint http://YOUR_IP:${PORT}/mcp
Headers: x-api-key or x-user-id
Server: http://localhost:${PORT}
MCP: http://localhost:${PORT}/mcp
Auth: ${process.env.MCP_REQUIRE_AUTH === 'true' ? 'ENABLED' : 'DISABLED (dev)'}
Timeout: ${REQUEST_TIMEOUT}ms
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
Tools: 22
`);
});
// Graceful shutdown
process.on('SIGINT', async () => {
log('info', '\nShutting down MCP server...');
await prisma.$disconnect();
process.exit(0);
});
// ── Shutdown ─────────────────────────────────────────────────────────────────
process.on('SIGTERM', async () => {
log('info', '\nShutting down MCP server...');
async function shutdown() {
log('info', 'Shutting down...');
await prisma.$disconnect();
process.exit(0);
});
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
process.on('unhandledRejection', (reason) => log('error', 'Unhandled rejection:', reason));

View File

@@ -1,31 +1,19 @@
#!/usr/bin/env node
/**
* Memento MCP Server - Stdio Transport (Optimized)
* Memento MCP Server - Stdio Transport
*
* Performance improvements:
* - Prisma connection pooling
* - Prepared statements caching
* - Optimized JSON serialization
* - Lazy user resolution
*
* Environment variables:
* DATABASE_URL - Prisma database URL (default: ../../memento-note/prisma/dev.db)
* USER_ID - Optional user ID to filter data
* APP_BASE_URL - Optional Next.js app URL for AI features (default: http://localhost:3000)
* MCP_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
* Environment:
* DATABASE_URL Prisma database URL
* USER_ID Optional user ID filter
* APP_BASE_URL Next.js app URL (default: http://localhost:3000)
* MCP_LOG_LEVEL debug, info, warn, error (default: info)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { PrismaClient } from '@prisma/client';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { registerTools } from './tools.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration
const LOG_LEVEL = process.env.MCP_LOG_LEVEL || 'info';
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
@@ -36,44 +24,24 @@ function log(level, ...args) {
}
}
// Database - requires DATABASE_URL environment variable
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('ERROR: DATABASE_URL environment variable is required');
console.error('ERROR: DATABASE_URL is required');
process.exit(1);
}
// OPTIMIZED: Prisma client with connection pooling and prepared statements
const isPostgres = databaseUrl.startsWith('postgresql://') || databaseUrl.startsWith('postgres://');
const prisma = new PrismaClient({
datasources: {
db: { url: databaseUrl },
db: { url: isPostgres ? `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}connection_limit=10&pool_timeout=10` : databaseUrl },
},
// SQLite optimizations
log: LOG_LEVEL === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
});
// Connection health check
let isConnected = false;
async function checkConnection() {
try {
await prisma.$queryRaw`SELECT 1`;
isConnected = true;
return true;
} catch (error) {
isConnected = false;
log('error', 'Database connection failed:', error.message);
return false;
}
}
const server = new Server(
{
name: 'memento-mcp-server',
version: '3.1.0',
},
{
capabilities: { tools: {} },
},
{ name: 'memento-mcp-server', version: '3.2.0' },
{ capabilities: { tools: {} } },
);
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
@@ -84,21 +52,19 @@ registerTools(server, prisma, {
});
async function main() {
// Verify database connection on startup
const connected = await checkConnection();
if (!connected) {
console.error('FATAL: Could not connect to database');
try {
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
console.error('FATAL: Database connection failed:', error.message);
process.exit(1);
}
const transport = new StdioServerTransport();
await server.connect(transport);
log('info', `Memento MCP Server v3.1.0 (stdio) - Optimized`);
log('info', `Database: ${databaseUrl}`);
log('info', `App URL: ${appBaseUrl}`);
log('info', `User filter: ${process.env.USER_ID || 'none (all data)'}`);
log('debug', 'Performance optimizations enabled: connection pooling, batch operations, caching');
log('info', `Memento MCP Server v3.2.0 (stdio)`);
log('info', `Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}`);
log('info', `User filter: ${process.env.USER_ID || 'none'}`);
}
main().catch((error) => {
@@ -106,24 +72,13 @@ main().catch((error) => {
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
log('info', 'Shutting down gracefully...');
async function shutdown() {
log('info', 'Shutting down...');
await prisma.$disconnect();
process.exit(0);
});
}
process.on('SIGTERM', async () => {
log('info', 'Shutting down gracefully...');
await prisma.$disconnect();
process.exit(0);
});
// Handle uncaught errors
process.on('uncaughtException', (error) => {
log('error', 'Uncaught exception:', error.message);
});
process.on('unhandledRejection', (reason) => {
log('error', 'Unhandled rejection:', reason);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => log('error', 'Uncaught:', err.message));
process.on('unhandledRejection', (reason) => log('error', 'Unhandled:', reason));

View File

@@ -848,20 +848,6 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",

File diff suppressed because one or more lines are too long

View File

@@ -116,40 +116,26 @@ Prisma.NullTypes = {
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
});
exports.Prisma.NoteScalarFieldEnum = {
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
embedding: 'embedding',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
cardSizeMode: 'cardSizeMode',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
updatedAt: 'updatedAt'
};
exports.Prisma.NotebookScalarFieldEnum = {
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
exports.Prisma.NoteScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
trashedAt: 'trashedAt',
type: 'type',
dismissedFromRecent: 'dismissedFromRecent',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
contentUpdatedAt: 'contentUpdatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
};
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
embedding: 'embedding',
createdAt: 'createdAt'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
expires: 'expires'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SystemConfigScalarFieldEnum = {
key: 'key',
value: 'value'
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
desc: 'desc'
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
Note: 'Note',
User: 'User',
Notebook: 'Notebook',
Label: 'Label',
User: 'User',
Note: 'Note',
NoteEmbedding: 'NoteEmbedding',
NoteShare: 'NoteShare',
Account: 'Account',
Session: 'Session',
VerificationToken: 'VerificationToken',
NoteShare: 'NoteShare',
SystemConfig: 'SystemConfig',
AiFeedback: 'AiFeedback',
MemoryEchoInsight: 'MemoryEchoInsight',

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-07b35a59db17a461d4c7b787cc433edb9e7b79a627ae71660fd00cce5311cf75",
"name": "prisma-client-8c3c28a242bf05b03713c0c3d78783f929261d76a15352bcfc52a1cfa1e7f92a",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@@ -1,44 +1,46 @@
// ============================================================================
// MCP Server Schema — exact copy from memento-note (source of truth)
// Only includes models used by MCP tools.
// Do NOT modify independently — always sync with memento-note/prisma/schema.prisma
// ============================================================================
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
binaryTargets = ["linux-musl-openssl-3.0.x", "native"]
}
datasource db {
provider = "sqlite"
url = "file:../../memento-note/prisma/dev.db"
provider = "postgresql"
url = env("DATABASE_URL")
}
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
type String @default("text")
checkItems String?
labels String?
images String?
links String?
reminder DateTime?
isReminderDone Boolean @default(false)
reminderRecurrence String?
reminderLocation String?
isMarkdown Boolean @default(false)
size String @default("small")
embedding String?
sharedWith String?
userId String?
order Int @default(0)
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
autoGenerated Boolean?
aiProvider String?
aiConfidence Int?
language String?
languageConfidence Float?
lastAiAnalysis DateTime?
// ── Core models (used by MCP tools) ─────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
password String?
role String @default("USER")
image String?
theme String @default("light")
cardSizeMode String @default("variable")
resetToken String? @unique
resetTokenExpiry DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
aiFeedback AiFeedback[]
labels Label[]
memoryEchoInsights MemoryEchoInsight[]
notes Note[]
sentShares NoteShare[] @relation("SentShares")
receivedShares NoteShare[] @relation("ReceivedShares")
notebooks Notebook[]
sessions Session[]
aiSettings UserAISettings?
}
model Notebook {
@@ -50,33 +52,115 @@ model Notebook {
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
labels Label[]
notes Note[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, order])
@@index([userId])
}
model Label {
id String @id @default(cuid())
id String @id @default(cuid())
name String
color String @default("gray")
color String @default("gray")
notebookId String?
userId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: Cascade)
notes Note[] @relation("LabelToNote")
@@unique([notebookId, name])
@@index([notebookId])
@@index([userId])
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
password String?
role String @default("USER")
image String?
theme String @default("light")
resetToken String? @unique
resetTokenExpiry DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model Note {
id String @id @default(cuid())
title String?
content String
color String @default("default")
isPinned Boolean @default(false)
isArchived Boolean @default(false)
trashedAt DateTime?
type String @default("text")
dismissedFromRecent Boolean @default(false)
checkItems String?
labels String?
images String?
links String?
reminder DateTime?
isReminderDone Boolean @default(false)
reminderRecurrence String?
reminderLocation String?
isMarkdown Boolean @default(false)
size String @default("small")
sharedWith String?
userId String?
order Int @default(0)
notebookId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contentUpdatedAt DateTime @default(now())
autoGenerated Boolean?
aiProvider String?
aiConfidence Int?
language String?
languageConfidence Float?
lastAiAnalysis DateTime?
aiFeedback AiFeedback[]
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
notebook Notebook? @relation(fields: [notebookId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
shares NoteShare[]
labelRelations Label[] @relation("LabelToNote")
noteEmbedding NoteEmbedding?
@@index([isPinned])
@@index([isArchived])
@@index([trashedAt])
@@index([order])
@@index([reminder])
@@index([userId])
@@index([userId, notebookId])
}
model NoteEmbedding {
id String @id @default(cuid())
noteId String @unique
embedding String
createdAt DateTime @default(now())
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId])
}
model NoteShare {
id String @id @default(cuid())
noteId String
userId String
sharedBy String
status String @default("pending")
permission String @default("view")
notifiedAt DateTime?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sharer User @relation("SentShares", fields: [sharedBy], references: [id], onDelete: Cascade)
user User @relation("ReceivedShares", fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@unique([noteId, userId])
@@index([userId])
@@index([status])
@@index([sharedBy])
}
// ── Supporting models (used for auth, AI features, config) ──────────────────
model Account {
userId String
type String
@@ -91,6 +175,7 @@ model Account {
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
@@ -101,6 +186,7 @@ model Session {
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
@@ -111,21 +197,6 @@ model VerificationToken {
@@id([identifier, token])
}
model NoteShare {
id String @id @default(cuid())
noteId String
userId String
sharedBy String
status String @default("pending")
permission String @default("view")
notifiedAt DateTime?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([noteId, userId])
}
model SystemConfig {
key String @id
value String
@@ -141,6 +212,12 @@ model AiFeedback {
correctedContent String?
metadata String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId])
@@index([userId])
@@index([feature])
}
model MemoryEchoInsight {
@@ -154,8 +231,13 @@ model MemoryEchoInsight {
viewed Boolean @default(false)
feedback String?
dismissed Boolean @default(false)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id], onDelete: Cascade)
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id], onDelete: Cascade)
@@unique([userId, insightDate])
@@index([userId, insightDate])
@@index([userId, dismissed])
}
model UserAISettings {
@@ -169,8 +251,15 @@ model UserAISettings {
preferredLanguage String @default("auto")
fontSize String @default("medium")
demoMode Boolean @default(false)
showRecentNotes Boolean @default(false)
showRecentNotes Boolean @default(true)
notesViewMode String @default("masonry")
emailNotifications Boolean @default(false)
desktopNotifications Boolean @default(false)
anonymousAnalytics Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])
@@index([aiProvider])
@@index([memoryEchoFrequency])
@@index([preferredLanguage])
}

View File

@@ -116,40 +116,26 @@ Prisma.NullTypes = {
*/
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
});
exports.Prisma.NoteScalarFieldEnum = {
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
embedding: 'embedding',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
cardSizeMode: 'cardSizeMode',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
updatedAt: 'updatedAt'
};
exports.Prisma.NotebookScalarFieldEnum = {
@@ -173,17 +159,57 @@ exports.Prisma.LabelScalarFieldEnum = {
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
exports.Prisma.NoteScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
password: 'password',
role: 'role',
image: 'image',
theme: 'theme',
resetToken: 'resetToken',
resetTokenExpiry: 'resetTokenExpiry',
title: 'title',
content: 'content',
color: 'color',
isPinned: 'isPinned',
isArchived: 'isArchived',
trashedAt: 'trashedAt',
type: 'type',
dismissedFromRecent: 'dismissedFromRecent',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',
links: 'links',
reminder: 'reminder',
isReminderDone: 'isReminderDone',
reminderRecurrence: 'reminderRecurrence',
reminderLocation: 'reminderLocation',
isMarkdown: 'isMarkdown',
size: 'size',
sharedWith: 'sharedWith',
userId: 'userId',
order: 'order',
notebookId: 'notebookId',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
contentUpdatedAt: 'contentUpdatedAt',
autoGenerated: 'autoGenerated',
aiProvider: 'aiProvider',
aiConfidence: 'aiConfidence',
language: 'language',
languageConfidence: 'languageConfidence',
lastAiAnalysis: 'lastAiAnalysis'
};
exports.Prisma.NoteEmbeddingScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
embedding: 'embedding',
createdAt: 'createdAt'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -218,19 +244,6 @@ exports.Prisma.VerificationTokenScalarFieldEnum = {
expires: 'expires'
};
exports.Prisma.NoteShareScalarFieldEnum = {
id: 'id',
noteId: 'noteId',
userId: 'userId',
sharedBy: 'sharedBy',
status: 'status',
permission: 'permission',
notifiedAt: 'notifiedAt',
respondedAt: 'respondedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SystemConfigScalarFieldEnum = {
key: 'key',
value: 'value'
@@ -273,6 +286,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'
@@ -283,6 +297,11 @@ exports.Prisma.SortOrder = {
desc: 'desc'
};
exports.Prisma.QueryMode = {
default: 'default',
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
@@ -290,14 +309,15 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
Note: 'Note',
User: 'User',
Notebook: 'Notebook',
Label: 'Label',
User: 'User',
Note: 'Note',
NoteEmbedding: 'NoteEmbedding',
NoteShare: 'NoteShare',
Account: 'Account',
Session: 'Session',
VerificationToken: 'VerificationToken',
NoteShare: 'NoteShare',
SystemConfig: 'SystemConfig',
AiFeedback: 'AiFeedback',
MemoryEchoInsight: 'MemoryEchoInsight',

View File

@@ -870,6 +870,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

@@ -1,13 +1,8 @@
/**
* Memento MCP Server - Optimized Tool Definitions & Handlers
* Memento MCP Server - Tool Definitions & Handlers
*
* Performance optimizations:
* - O(1) API key lookup with caching
* - Batch operations for imports
* - Parallel promise execution
* - HTTP timeout wrapper
* - N+1 query fixes
* - Connection pooling
* Fast, minimal overhead. All queries filter trashed notes.
* Compact JSON output. Direct DB lookups.
*/
import {
@@ -18,12 +13,12 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { requestContext } from './request-context.js';
// ─── Configuration ─────────────────────────────────────────────────────────
const DEFAULT_SEARCH_LIMIT = 50;
const DEFAULT_NOTES_LIMIT = 100;
const MAX_NOTES_LIMIT = 500;
// ─── Helpers ────────────────────────────────────────────────────────────────
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
export function parseNote(dbNote) {
if (!dbNote) return null;
@@ -66,65 +61,60 @@ export function parseNoteLightweight(dbNote) {
function textResult(data) {
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
content: [{ type: 'text', text: JSON.stringify(data) }],
};
}
// ─── Tool Schemas ───────────────────────────────────────────────────────────
const NOTE_COLORS = ['default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
// ─── Tool Definitions ──────────────────────────────────────────────────────
const toolDefinitions = [
// ═══════════════════════════════════════════════════════════
// NOTE TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ NOTES ═══
{
name: 'create_note',
description: 'Create a new note. Supports text and checklist types, colors, labels, images, links, reminders, markdown, and notebook assignment.',
description: 'Create a new note. Set content (required), optional title, color, labels, notebook assignment, reminder, or checklist items.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Note title (optional)' },
content: { type: 'string', description: 'Note content (required)' },
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
title: { type: 'string', description: 'Note title' },
content: { type: 'string', description: 'Note body text (required)' },
color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' },
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
checkItems: {
type: 'array',
description: 'Checklist items (when type is checklist)',
description: 'Checklist items (when type=list)',
items: {
type: 'object',
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
required: ['id', 'text', 'checked'],
},
},
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } },
isPinned: { type: 'boolean', description: 'Pin to top', default: false },
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
images: { type: 'array', description: 'Image URLs', items: { type: 'string' } },
links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } },
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
isReminderDone: { type: 'boolean', default: false },
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
reminderLocation: { type: 'string', description: 'Location-based reminder' },
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' },
reminderLocation: { type: 'string', description: 'Location string' },
isMarkdown: { type: 'boolean', description: 'Render as markdown', default: false },
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
notebookId: { type: 'string', description: 'Notebook to assign the note to' },
notebookId: { type: 'string', description: 'Assign to notebook' },
},
required: ['content'],
},
},
{
name: 'get_notes',
description: 'Get notes with optional filters. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for complete data.',
description: 'List notes. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for full payloads.',
inputSchema: {
type: 'object',
properties: {
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
search: { type: 'string', description: 'Filter by keyword in title/content' },
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
search: { type: 'string', description: 'Keyword filter on title/content' },
notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' },
fullDetails: { type: 'boolean', description: 'Full payload including images', default: false },
limit: { type: 'number', description: `Max results (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
},
},
},
@@ -139,14 +129,14 @@ const toolDefinitions = [
},
{
name: 'update_note',
description: 'Update an existing note. Only include fields you want to change.',
description: 'Update a note. Only fields you include will be changed.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
title: { type: 'string' },
content: { type: 'string' },
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
color: { type: 'string', description: `One of: ${NOTE_COLORS}` },
type: { type: 'string', enum: ['text', 'checklist'] },
checkItems: {
type: 'array',
@@ -173,7 +163,7 @@ const toolDefinitions = [
},
{
name: 'delete_note',
description: 'Delete a note by ID.',
description: 'Permanently delete a note.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
@@ -187,7 +177,7 @@ const toolDefinitions = [
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
notebookId: { type: 'string', description: 'Limit search to a notebook' },
notebookId: { type: 'string', description: 'Limit to a notebook' },
includeArchived: { type: 'boolean', default: false },
},
required: ['query'],
@@ -195,12 +185,12 @@ const toolDefinitions = [
},
{
name: 'move_note',
description: 'Move a note to a different notebook. Pass null for notebookId to move to Inbox.',
description: 'Move a note to a notebook. Set notebookId to null to move to Inbox.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
notebookId: { type: 'string', description: 'Target notebook ID, or null/empty for Inbox' },
notebookId: { type: 'string', description: 'Target notebook ID, or null for Inbox' },
},
required: ['id'],
},
@@ -225,18 +215,18 @@ const toolDefinitions = [
},
{
name: 'export_notes',
description: 'Export all notes, labels, and notebooks as a JSON object.',
description: 'Export all notes, notebooks, and labels as JSON.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'import_notes',
description: 'Import notes from a previously exported JSON object. Skips duplicates by name.',
description: 'Import notes, notebooks, and labels from a previous export. Duplicates are skipped.',
inputSchema: {
type: 'object',
properties: {
data: {
type: 'object',
description: 'The exported JSON data (from export_notes)',
description: 'Export JSON (from export_notes)',
properties: {
version: { type: 'string' },
data: {
@@ -254,31 +244,29 @@ const toolDefinitions = [
},
},
// ═══════════════════════════════════════════════════════════
// NOTEBOOK TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ NOTEBOOKS ═══
{
name: 'create_notebook',
description: 'Create a new notebook.',
description: 'Create a notebook with a name, icon, and color.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Notebook name' },
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
icon: { type: 'string', description: 'Icon (emoji)', default: '📁' },
color: { type: 'string', description: 'Hex color', default: '#3B82F6' },
order: { type: 'number', description: 'Sort position (auto if omitted)' },
},
required: ['name'],
},
},
{
name: 'get_notebooks',
description: 'Get all notebooks with label and note counts.',
description: 'List all notebooks with label and note counts.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_notebook',
description: 'Get a notebook by ID, including its notes.',
description: 'Get a notebook by ID including its notes.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Notebook ID' } },
@@ -287,7 +275,7 @@ const toolDefinitions = [
},
{
name: 'update_notebook',
description: 'Update a notebook\'s name, icon, color, or order.',
description: 'Update a notebook name, icon, color, or order.',
inputSchema: {
type: 'object',
properties: {
@@ -302,7 +290,7 @@ const toolDefinitions = [
},
{
name: 'delete_notebook',
description: 'Delete a notebook. Notes inside will be moved to Inbox.',
description: 'Delete a notebook. Notes inside are moved to Inbox.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Notebook ID' } },
@@ -311,13 +299,13 @@ const toolDefinitions = [
},
{
name: 'reorder_notebooks',
description: 'Reorder notebooks. Pass an ordered array of notebook IDs.',
description: 'Set the order of all notebooks by passing their IDs in sequence.',
inputSchema: {
type: 'object',
properties: {
notebookIds: {
type: 'array',
description: 'Notebook IDs in the desired order',
description: 'Notebook IDs in desired order',
items: { type: 'string' },
},
},
@@ -325,35 +313,33 @@ const toolDefinitions = [
},
},
// ═══════════════════════════════════════════════════════════
// LABEL TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ LABELS ═══
{
name: 'create_label',
description: 'Create a label in a notebook.',
description: 'Create a label inside a notebook.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Label name' },
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
color: { type: 'string', description: `Color: ${LABEL_COLORS}` },
notebookId: { type: 'string', description: 'Parent notebook ID' },
},
required: ['name', 'notebookId'],
},
},
{
name: 'get_labels',
description: 'Get all labels, optionally filtered by notebook.',
description: 'List labels, optionally filtered by notebook.',
inputSchema: {
type: 'object',
properties: {
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
notebookId: { type: 'string', description: 'Filter by notebook' },
},
},
},
{
name: 'update_label',
description: 'Update a label\'s name or color.',
description: 'Update a label name or color.',
inputSchema: {
type: 'object',
properties: {
@@ -366,7 +352,7 @@ const toolDefinitions = [
},
{
name: 'delete_label',
description: 'Delete a label by ID.',
description: 'Delete a label.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Label ID' } },
@@ -374,33 +360,23 @@ const toolDefinitions = [
},
},
// ═══════════════════════════════════════════════════════════
// REMINDER TOOLS
// ═══════════════════════════════════════════════════════════
// ═══ REMINDERS ═══
{
name: 'get_due_reminders',
description: 'Get all notes with due reminders that haven\'t been processed yet. Designed for cron/automation use.',
description: 'Get notes with due reminders. Designed for cron/automation.',
inputSchema: { type: 'object', properties: {} },
},
];
// ─── Tool Handlers ──────────────────────────────────────────────────────────
/**
* Register all tools and handlers on an MCP Server instance.
*
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
* @param {import('@prisma/client').PrismaClient} prisma
*/
export function registerTools(server, prisma) {
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
const getResolvedUserId = () => {
const store = requestContext.getStore();
return store?.userId || null;
};
// Fallback: auto-detect first user when no auth context
let fallbackUserId = null;
let fallbackPromise = null;
@@ -418,22 +394,24 @@ export function registerTools(server, prisma) {
return fallbackPromise;
};
// ── List Tools ────────────────────────────────────────────────────────────
const noteWhere = (resolvedUserId, extra = {}) => ({
trashedAt: null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
...extra,
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: toolDefinitions };
});
// ── Call Tools ────────────────────────────────────────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const resolvedUserId = getResolvedUserId();
const uid = getResolvedUserId();
try {
switch (name) {
// ═══════════════════════════════════════════════════════
// NOTES
// ═══════════════════════════════════════════════════════
// ═══ NOTES ═══
case 'create_note': {
const data = {
title: args.title || null,
@@ -453,31 +431,29 @@ export function registerTools(server, prisma) {
isMarkdown: args.isMarkdown || false,
size: args.size || 'small',
notebookId: args.notebookId || null,
userId: uid || await ensureUserId(),
};
if (resolvedUserId) data.userId = resolvedUserId;
else data.userId = await ensureUserId();
const note = await prisma.note.create({ data });
return textResult(parseNote(note));
}
case 'get_notes': {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
if (!args?.includeArchived) where.isArchived = false;
const extra = {};
if (!args?.includeArchived) extra.isArchived = false;
if (args?.search) {
where.OR = [
extra.OR = [
{ title: { contains: args.search } },
{ content: { contains: args.search } },
];
}
if (args?.notebookId) {
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, MAX_NOTES_LIMIT);
const notes = await prisma.note.findMany({
where,
where: noteWhere(uid, extra),
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
take: limit,
});
@@ -487,50 +463,50 @@ export function registerTools(server, prisma) {
}
case 'get_note': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
return textResult(parseNote(note));
}
case 'update_note': {
const updateData = {};
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
for (const f of fields) {
if (f in args) updateData[f] = args[f];
const d = {};
const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
for (const f of simpleFields) {
if (f in args) d[f] = args[f];
}
if ('content' in args) updateData.content = args.content;
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
if ('labels' in args) updateData.labels = args.labels ?? null;
if ('images' in args) updateData.images = args.images ?? null;
if ('links' in args) updateData.links = args.links ?? null;
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
updateData.updatedAt = new Date();
if ('content' in args) d.content = args.content;
if ('checkItems' in args) d.checkItems = args.checkItems ?? null;
if ('labels' in args) d.labels = args.labels ?? null;
if ('images' in args) d.images = args.images ?? null;
if ('links' in args) d.links = args.links ?? null;
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
d.updatedAt = new Date();
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.update({ where, data: updateData });
const note = await prisma.note.update({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
data: d,
});
return textResult(parseNote(note));
}
case 'delete_note': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
await prisma.note.delete({ where });
return textResult({ success: true, message: 'Note deleted' });
await prisma.note.delete({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
return textResult({ success: true, deleted: args.id });
}
case 'search_notes': {
const where = {
const where = noteWhere(uid, {
isArchived: args.includeArchived || false,
OR: [
{ title: { contains: args.query } },
{ content: { contains: args.query } },
],
};
if (resolvedUserId) where.userId = resolvedUserId;
if (args.notebookId) where.notebookId = args.notebookId;
...(args.notebookId ? { notebookId: args.notebookId } : {}),
});
const notes = await prisma.note.findMany({
where,
@@ -541,79 +517,61 @@ export function registerTools(server, prisma) {
}
case 'move_note': {
const noteWhere = { id: args.id };
if (resolvedUserId) noteWhere.userId = resolvedUserId;
const targetId = args.notebookId || null;
const targetNotebookId = args.notebookId || null;
// Optimized: Parallel execution
const [note, notebook] = await Promise.all([
prisma.note.update({
where: noteWhere,
data: { notebookId: targetNotebookId, updatedAt: new Date() },
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
data: { notebookId: targetId, updatedAt: new Date() },
}),
targetNotebookId
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
targetId
? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } })
: Promise.resolve(null),
]);
const notebookName = notebook?.name || 'Inbox';
return textResult({
success: true,
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
message: `Note moved to ${notebookName}`,
id: note.id,
notebookId: note.notebookId,
notebookName: notebook?.name || 'Inbox',
});
}
case 'toggle_pin': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where,
where: { id: args.id },
data: { isPinned: !note.isPinned },
});
return textResult(parseNote(updated));
}
case 'toggle_archive': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const note = await prisma.note.findUnique({ where });
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where,
where: { id: args.id },
data: { isArchived: !note.isArchived },
});
return textResult(parseNote(updated));
}
case 'export_notes': {
const noteWhere = {};
const nbWhere = {};
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
const nbWhere = uid ? { userId: uid } : {};
// Optimized: Parallel queries
const [notes, notebooks, labels] = await Promise.all([
prisma.note.findMany({
where: noteWhere,
prisma.note.findMany({
where: noteWhere(uid),
orderBy: { updatedAt: 'desc' },
select: {
id: true,
title: true,
content: true,
color: true,
type: true,
isPinned: true,
isArchived: true,
isMarkdown: true,
size: true,
labels: true,
notebookId: true,
createdAt: true,
updatedAt: true,
id: true, title: true, content: true, color: true, type: true,
isPinned: true, isArchived: true, isMarkdown: true, size: true,
labels: true, notebookId: true, createdAt: true, updatedAt: true,
},
}),
prisma.notebook.findMany({
@@ -626,9 +584,8 @@ export function registerTools(server, prisma) {
}),
]);
// Filter labels by userId in memory (faster than multiple queries)
const filteredLabels = resolvedUserId
? labels.filter(l => l.notebook?.userId === resolvedUserId)
const filteredLabels = uid
? labels.filter(l => l.notebook?.userId === uid)
: labels;
return textResult({
@@ -636,32 +593,16 @@ export function registerTools(server, prisma) {
exportDate: new Date().toISOString(),
data: {
notes: notes.map(n => ({
id: n.id,
title: n.title,
content: n.content,
color: n.color,
type: n.type,
isPinned: n.isPinned,
isArchived: n.isArchived,
isMarkdown: n.isMarkdown,
size: n.size,
labels: Array.isArray(n.labels) ? n.labels : [],
notebookId: n.notebookId,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
id: n.id, title: n.title, content: n.content, color: n.color, type: n.type,
isPinned: n.isPinned, isArchived: n.isArchived, isMarkdown: n.isMarkdown,
size: n.size, labels: Array.isArray(n.labels) ? n.labels : [],
notebookId: n.notebookId, createdAt: n.createdAt, updatedAt: n.updatedAt,
})),
notebooks: notebooks.map(nb => ({
id: nb.id,
name: nb.name,
icon: nb.icon,
color: nb.color,
noteCount: nb._count.notes,
id: nb.id, name: nb.name, icon: nb.icon, color: nb.color, noteCount: nb._count.notes,
})),
labels: filteredLabels.map(l => ({
id: l.id,
name: l.name,
color: l.color,
notebookId: l.notebookId,
id: l.id, name: l.name, color: l.color, notebookId: l.notebookId,
})),
},
});
@@ -671,60 +612,51 @@ export function registerTools(server, prisma) {
const importData = args.data;
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
// OPTIMIZED: Batch operations with Promise.all for notebooks
if (importData.data?.notebooks?.length > 0) {
const existingNotebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
const existing = await prisma.notebook.findMany({
where: uid ? { userId: uid } : {},
select: { name: true },
});
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
const existingNames = new Set(existing.map(nb => nb.name));
const notebooksToCreate = importData.data.notebooks
const toCreate = importData.data.notebooks
.filter(nb => !existingNames.has(nb.name))
.map(nb => prisma.notebook.create({
data: {
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(resolvedUserId ? { userId: resolvedUserId } : {}),
},
.map(nb => ({
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(uid ? { userId: uid } : {}),
}));
await Promise.all(notebooksToCreate);
importedNotebooks = notebooksToCreate.length;
if (toCreate.length > 0) {
await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true });
importedNotebooks = toCreate.length;
}
}
// OPTIMIZED: Batch labels
if (importData.data?.labels?.length > 0) {
const notebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
where: uid ? { userId: uid } : {},
select: { id: true },
});
const notebookIds = new Set(notebooks.map(nb => nb.id));
const nbIds = new Set(notebooks.map(nb => nb.id));
const existingLabels = await prisma.label.findMany({
where: { notebookId: { in: Array.from(notebookIds) } },
const existing = await prisma.label.findMany({
where: { notebookId: { in: Array.from(nbIds) } },
select: { name: true, notebookId: true },
});
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
const existingKeys = new Set(existing.map(l => `${l.notebookId}:${l.name}`));
const labelsToCreate = [];
for (const label of importData.data.labels) {
if (label.notebookId && notebookIds.has(label.notebookId)) {
const key = `${label.notebookId}:${label.name}`;
if (!existingLabelKeys.has(key)) {
labelsToCreate.push(prisma.label.create({
data: { name: label.name, color: label.color, notebookId: label.notebookId },
}));
}
}
const toCreate = importData.data.labels
.filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`))
.map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId }));
if (toCreate.length > 0) {
await prisma.label.createMany({ data: toCreate, skipDuplicates: true });
importedLabels = toCreate.length;
}
await Promise.all(labelsToCreate);
importedLabels = labelsToCreate.length;
}
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
if (importData.data?.notes?.length > 0) {
const notesData = importData.data.notes.map(note => ({
title: note.title,
@@ -737,22 +669,14 @@ export function registerTools(server, prisma) {
size: note.size || 'small',
labels: note.labels ?? null,
notebookId: note.notebookId || null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
...(uid ? { userId: uid } : {}),
}));
// Try createMany first (faster), fall back to Promise.all
try {
const result = await prisma.note.createMany({
data: notesData,
skipDuplicates: true,
});
const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true });
importedNotes = result.count;
} catch {
// Fallback to individual creates
const creates = notesData.map(data =>
prisma.note.create({ data }).catch(() => null)
);
const results = await Promise.all(creates);
const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null)));
importedNotes = results.filter(r => r !== null).length;
}
}
@@ -763,27 +687,23 @@ export function registerTools(server, prisma) {
});
}
// ═══════════════════════════════════════════════════════
// NOTEBOOKS
// ═══════════════════════════════════════════════════════
// ═══ NOTEBOOKS ═══
case 'create_notebook': {
const highestOrder = await prisma.notebook.findFirst({
where: resolvedUserId ? { userId: resolvedUserId } : {},
const highest = await prisma.notebook.findFirst({
where: uid ? { userId: uid } : {},
orderBy: { order: 'desc' },
select: { order: true },
});
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
const data = {
name: args.name.trim(),
icon: args.icon || '📁',
color: args.color || '#3B82F6',
order: nextOrder,
};
data.userId = resolvedUserId || await ensureUserId();
const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1;
const notebook = await prisma.notebook.create({
data,
data: {
name: args.name.trim(),
icon: args.icon || '📁',
color: args.color || '#3B82F6',
order: nextOrder,
userId: uid || await ensureUserId(),
},
include: { labels: true, _count: { select: { notes: true } } },
});
@@ -791,9 +711,7 @@ export function registerTools(server, prisma) {
}
case 'get_notebooks': {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
const where = uid ? { userId: uid } : {};
const notebooks = await prisma.notebook.findMany({
where,
include: {
@@ -807,12 +725,10 @@ export function registerTools(server, prisma) {
}
case 'get_notebook': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
const notebook = await prisma.notebook.findUnique({
where,
include: { labels: true, notes: true, _count: { select: { notes: true } } },
include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } },
});
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
@@ -824,18 +740,16 @@ export function registerTools(server, prisma) {
}
case 'update_notebook': {
const updateData = {};
if ('name' in args) updateData.name = args.name.trim();
if ('icon' in args) updateData.icon = args.icon;
if ('color' in args) updateData.color = args.color;
if ('order' in args) updateData.order = args.order;
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
const d = {};
if ('name' in args) d.name = args.name.trim();
if ('icon' in args) d.icon = args.icon;
if ('color' in args) d.color = args.color;
if ('order' in args) d.order = args.order;
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
const notebook = await prisma.notebook.update({
where,
data: updateData,
data: d,
include: { labels: true, _count: { select: { notes: true } } },
});
@@ -843,38 +757,29 @@ export function registerTools(server, prisma) {
}
case 'delete_notebook': {
const where = { id: args.id };
if (resolvedUserId) where.userId = resolvedUserId;
// Move notes to inbox before deleting
await prisma.$transaction([
prisma.note.updateMany({
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
data: { notebookId: null },
}),
prisma.notebook.delete({ where }),
prisma.notebook.delete({
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
}),
]);
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
}
case 'reorder_notebooks': {
const ids = args.notebookIds;
// Optimized: Verify ownership in one query
const where = { id: { in: ids } };
if (resolvedUserId) where.userId = resolvedUserId;
const existingNotebooks = await prisma.notebook.findMany({
where,
select: { id: true },
});
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
const missingIds = ids.filter(id => !existingIds.has(id));
if (missingIds.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
const existingIds = new Set(existing.map(nb => nb.id));
const missing = ids.filter(id => !existingIds.has(id));
if (missing.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`);
}
await prisma.$transaction(
@@ -882,12 +787,10 @@ export function registerTools(server, prisma) {
prisma.notebook.update({ where: { id }, data: { order: index } })
)
);
return textResult({ success: true, message: 'Notebooks reordered' });
return textResult({ success: true });
}
// ═══════════════════════════════════════════════════════
// LABELS - OPTIMIZED to fix N+1 query
// ═══════════════════════════════════════════════════════
// ═══ LABELS ═══
case 'create_label': {
const existing = await prisma.label.findFirst({
where: { name: args.name.trim(), notebookId: args.notebookId },
@@ -908,49 +811,37 @@ export function registerTools(server, prisma) {
const where = {};
if (args?.notebookId) where.notebookId = args.notebookId;
// OPTIMIZED: Single query with include, then filter in memory
const labels = await prisma.label.findMany({
where,
include: { notebook: { select: { id: true, name: true, userId: true } } },
orderBy: { name: 'asc' },
});
// Filter by userId in memory (much faster than N+1 queries)
let filteredLabels = labels;
if (resolvedUserId) {
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
}
return textResult(filteredLabels);
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
return textResult(filtered);
}
case 'update_label': {
const updateData = {};
if ('name' in args) updateData.name = args.name.trim();
if ('color' in args) updateData.color = args.color;
const d = {};
if ('name' in args) d.name = args.name.trim();
if ('color' in args) d.color = args.color;
const label = await prisma.label.update({
where: { id: args.id },
data: updateData,
});
const label = await prisma.label.update({ where: { id: args.id }, data: d });
return textResult(label);
}
case 'delete_label': {
await prisma.label.delete({ where: { id: args.id } });
return textResult({ success: true, message: 'Label deleted' });
return textResult({ success: true });
}
// ═══════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════
// ═══ REMINDERS ═══
case 'get_due_reminders': {
const where = {
const where = noteWhere(uid, {
reminder: { not: null, lte: new Date() },
isReminderDone: false,
isArchived: false,
};
if (resolvedUserId) where.userId = resolvedUserId;
});
const reminders = await prisma.note.findMany({
where,
@@ -958,7 +849,6 @@ export function registerTools(server, prisma) {
orderBy: { reminder: 'asc' },
});
// Mark them as done
if (reminders.length > 0) {
await prisma.note.updateMany({
where: { id: { in: reminders.map(r => r.id) } },
@@ -966,7 +856,7 @@ export function registerTools(server, prisma) {
});
}
return textResult({ success: true, count: reminders.length, reminders });
return textResult({ count: reminders.length, reminders });
}
default:
@@ -974,7 +864,7 @@ export function registerTools(server, prisma) {
}
} catch (error) {
if (error instanceof McpError) throw error;
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
throw new McpError(ErrorCode.InternalError, `Tool error: ${error.message}`);
}
});
}
}