Voice Agent

MCP Server Improvements

Architecture audit and optimization recommendations
10 issues identified
4 high priority
3 medium priority
3 low priority
# Issue Priority Effort Project Impact
01 Split monolith tools.ts (10K lines) High Medium Fault Isolation
02 Eliminate self-call HTTP overhead High High AI Response Speed
03 Cache system settings per session High Low DB Load Reduction
04 Fix inconsistent error handling High Low Production Bug
05 Streamable HTTP session memory leak Medium Low Server Stability
06 Inconsistent delete confirmation pattern Medium Low User Experience
07 get_workspace_language breaks architecture Medium Low Silent Breakage Risk
08 Replace console.log with structured logging Low Low PII Exposure Risk
09 Add input validation beyond ObjectId Low Medium AI Error Recovery
10 Fix pagination offset vs page inconsistency Low Low Broken Feature
Kompot MCP Improvements Report — March 2026
Issue 01 of 10

Split the 10K-Line Monolith tools.ts

High Priority Medium Effort Fault Isolation

Single file, 10,227 lines, 190 switch cases

All tool definitions and handlers live in one file behind a single handleMCPToolCall function. This creates concrete project-level risks:

  • Fault isolation failure — a syntax error or broken import in any tool handler prevents the entire handleMCPToolCall function from loading, taking down all 190 tools at once
  • Untestable — you cannot unit-test a single tool handler in isolation; importing the file loads all 190 tools with all their dependencies
  • Slower builds — TypeScript recompiles the entire 10K-line file (with 190 inline type assertions) when any single tool changes, slowing down incremental builds
  • No selective composition — if a lightweight integration needs only contact tools, it must still load invoices, disputes, workflows, and everything else
tools.ts
10,227 lines • 190 case blocks
definitions + handlers + apiCall + helpers
Everything in a single file, single switch statement
lib/mcp/
  server.ts ← unchanged
  api-client.ts ← apiCall() + api() wrapper
  types.ts ← ToolDefinition, ToolHandler types
  tools/
    index.ts ← registry: collects all tools
    contacts.ts ← definition + handler together
    opportunities.ts
    tasks.ts
    invoices.ts
    projects.ts
    jobs.ts
    calls-sms.ts
    ... ← one file per domain (~15 modules)
tools/contacts.ts // Definition and handler live side-by-side export const contactTools: ToolModule = { tools: [ { name: 'search_contacts', description: '...', inputSchema: { ... } }, { name: 'create_contact', description: '...', inputSchema: { ... } }, ], handlers: { search_contacts: async (args, api) => { ... }, create_contact: async (args, api) => { ... }, }, };
Broken tool can't crash all 190 others
Unit-testable per domain
Faster incremental builds
Composable tool sets for integrations
Parallel development without conflicts
Issue 02 of 10

Eliminate Self-Call HTTP Overhead

High Priority High Effort AI Response Speed

Every MCP tool makes an HTTP round-trip to itself

Tool handlers call fetch('http://localhost:3000/api/...') for every operation — a full HTTP round-trip to reach a function running in the same process. This has two concrete project consequences:

  • Slower AI responses — LLM conversations feel sluggish because each tool call adds 3-10ms of unnecessary latency; multi-tool calls like get_contact_details (4 parallel fetches) compound this
  • No transaction support — because each operation is a separate HTTP request, you cannot wrap multi-step tool logic (e.g., create invoice + create products) in a single database transaction
MCP Handler
JSON.stringify
fetch(localhost)
HTTP Parse
API Route
Controller
MongoDB
4 unnecessary steps (serialize + HTTP + parse + routing) per tool call
ScenarioHTTP OverheadTool CallsWasted
Simple search~3-5 ms1~5 ms
get_contact_details~3-5 ms × 44 parallel~15 ms
Create invoice (auto-products)~3-5 ms × N1 + N products~25 ms
LLM session (20 tool calls)~3-5 ms × 20+20+~80 ms
MCP Handler
Controller
MongoDB
Direct function call — zero HTTP overhead

Incremental migration via service layer

Extract controller logic into importable service functions that both API routes and MCP tools can call directly. This also creates a reusable service layer for future integrations (webhooks, background jobs). Migrate one entity at a time, starting with the highest-traffic tools.

Before (current) const result = await api('POST', '/api/contacts/search', { search: query });
After (direct call) import { searchContacts } from '@/modules/workspace/contact/service'; const result = await searchContacts({ search: query, workspaceDb });
Faster AI conversations for users
Multi-step operations become transactional
Reusable service layer for webhooks/jobs
Better error traceability in production
Issue 03 of 10

Cache System Settings Per Session

High Priority Low Effort DB Load Reduction

Redundant DB queries on every tool call

Two functions query the same system settings data independently:

  • getEnabledMCPTools() — called on every tools/list request
  • isToolEnabled() — called on every single tool call

Both call getSystemSettingsInternal(workspaceDb) which queries MongoDB. In a typical LLM session with 20 tool calls, this results in 20+ redundant DB queries for data that rarely changes.

tools/list
getSystemSettingsInternal() → MongoDB
tool call #1
getSystemSettingsInternal() → MongoDB
tool call #2
getSystemSettingsInternal() → MongoDB
...
same query repeated N times
tool call #20
getSystemSettingsInternal() → MongoDB

TTL-based cache inside createMCPServer()

Cache the enabled tools list once per session with a 30–60 second TTL. If an admin changes the setting mid-session, it picks up within one TTL window.

function createMCPServer(userId, apiToken, workspaceDb) { let cachedEnabled: string[] | null = null; let cacheTime = 0; const TTL = 30_000; async function getEnabled() { if (cachedEnabled && Date.now() - cacheTime < TTL) return cachedEnabled; const settings = await getSystemSettingsInternal(workspaceDb); cachedEnabled = settings.ai?.mcpTools?.enabled ?? null; cacheTime = Date.now(); return cachedEnabled; } // Use getEnabled() in both ListTools and CallTool handlers }
Before

20 tool calls = 21 DB queries
(1 list + 20 calls)

After

20 tool calls = 1 DB query
(cached for 30s)

~20x fewer DB queries per MCP session
Reduced MongoDB load in production
Faster tool response time for end users
~15 lines of code — minimal risk
Issue 04 of 10

Fix Inconsistent Error Handling

High Priority Low Effort Production Bug

LLM clients silently miss validation errors — real bug in production

The CallToolRequestSchema handler catches thrown errors and marks them with isError: true. However, some tools return error objects instead of throwing. These reach the LLM as normal "successful" results, so the LLM may not realize the operation failed and won't retry or inform the user.

PatternCodeDetected as Error?Used In
throw Error throw new Error('Not found') Yes — caught by server.ts create_task, get_*_details
return { error } return { error: 'Invalid ID' } No — treated as success create_opportunity
return { success: false } return { success: false, error: '...' } No — treated as success invalidObjectIdError()
Returned error (not detected)
// server.ts wraps this as SUCCESS { content: [{ type: "text", text: '{"error":"Invalid priorityId"}' }], // isError is MISSING — LLM may not recognize failure }
Thrown error (correctly detected)
// server.ts catches and marks as error { content: [{ type: "text", text: "Error: Invalid priorityId" }], isError: true // LLM knows to retry or adjust }

Standardize on thrown errors for all failures

Replace all return { error: ... } and return { success: false, ... } with throw new Error(...). The catch block in server.ts:119 already handles this correctly.

// Before (silently passes as success) if (priorityId && !isValidObjectId(priorityId)) return { error: `Invalid priorityId` }; // After (correctly flagged as error) if (priorityId && !isValidObjectId(priorityId)) throw new Error(`Invalid priorityId format. Use search_dictionaries...`);
Users see correct error feedback from AI
LLM retries failed operations instead of silently moving on
Fixes an actual production bug
~20 lines changed — minimal risk
Issue 05 of 10

Streamable HTTP Session Memory Leak

Medium Priority Low Effort Server Stability

Abandoned MCP sessions accumulate until server restart

When a Claude Desktop user closes their laptop, loses connection, or their browser crashes, the MCP session stays in server memory permanently. Over days/weeks of production use, these orphaned sessions accumulate and consume memory. The server only recovers on restart.

  • SSE transport has cleanup via res.on('close') + heartbeat — works correctly
  • Streamable HTTP has no equivalent cleanup for abandoned sessions
  • Each orphaned session holds a Server instance + Transport instance + closures with workspace references
SSE Transport (has cleanup)
res.on('close', () => { clearInterval(heartbeat); transports.delete(sessionId); sessionTokens.delete(sessionId); sessionWsids.delete(sessionId); });
Streamable HTTP (no cleanup)
transport.onclose = () => { // Only fires on explicit close() // NOT on client disconnect/crash streamableTransports.delete(id); }; // No TTL, no periodic sweep // Abandoned sessions live forever

Add TTL-based eviction with periodic sweep

const SESSION_TTL = 30 * 60_000; // 30 minutes const sessionLastActive = new Map<string, number>(); // Update on every request sessionLastActive.set(sessionId, Date.now()); // Periodic cleanup every 60s setInterval(() => { const now = Date.now(); for (const [id, lastActive] of sessionLastActive) { if (now - lastActive > SESSION_TTL) { streamableTransports.get(id)?.close(); streamableTransports.delete(id); streamableServers.delete(id); sessionLastActive.delete(id); } } }, 60_000);
Server stays stable under long-running production use
No more memory growth between deploys
~20 lines of code — minimal risk
Issue 06 of 10

Inconsistent Delete Confirmation Pattern

Medium Priority Low Effort User Experience

Unpredictable behavior: some deletes ask twice, others execute immediately

Users interacting via Claude Desktop or other MCP clients experience inconsistent behavior: deleting a task requires a double-confirmation flow (two tool calls), while deleting a project, invoice, or contact happens instantly. There is no clear rule for which entities are "protected" and which aren't, making the system feel arbitrary.

ToolConfirmation?Pattern
delete_taskYesconfirmed param + warning message
delete_disputeYesconfirmed param + warning message
delete_projectNoImmediate delete
delete_jobNoImmediate delete
delete_fileNoImmediate delete
delete_interactionNoImmediate delete
delete_kb_articleNoImmediate delete
delete_opportunityNoImmediate delete
delete_contactNoImmediate delete
delete_invoiceNoImmediate delete
Option A: Confirm All Deletes

Apply the confirmed pattern to every delete tool. Maximum safety but adds one extra round-trip per delete.

Option B: Remove All Confirms

Remove the confirmed pattern entirely. LLMs already ask users before destructive operations. Reduces tool call complexity.

Recommendation: Option B

LLM clients (Claude, GPT) are trained to ask users before making destructive calls. The confirmed parameter forces an extra tool call round-trip that slows the conversation without adding real safety — the LLM already confirms with the user. Removing it gives users a faster, more consistent experience.

Consistent user experience across all entities
Faster delete operations for end users
Reduced token usage per conversation
Issue 07 of 10

get_workspace_language Breaks Architecture

Medium Priority Low Effort Silent Breakage Risk

One tool bypasses the entire api() abstraction layer

Every tool in the system uses the api() wrapper to make REST calls. The get_workspace_language tool is the sole exception — it directly imports Mongoose models and queries the database.

// The ONLY tool that does this: case 'get_workspace_language': { const { connectToDatabase } = await import('@/lib/mongodb'); const { default: UserModel } = await import('@/modules/manager/user/model'); await connectToDatabase(); const rawId = userId.includes(':') ? userId.split(':').pop()! : userId; const mgUser = await UserModel.findById(rawId).select('preferredLocale').lean(); return { success: true, language: mgUser?.preferredLocale || 'en' }; }
  • Silent breakage — if the User model schema, DB connection logic, or auth layer changes, this tool will silently break in production because it's outside the standard update path
  • Bypasses security layers — API routes may have access control, rate limiting, or audit logging that this direct DB call skips entirely
  • Duplicated logic — manually parses userId prefixes instead of using the existing extractValidObjectId() helper, creating a second place to maintain
  • Precedent risk — if this pattern gets copied to other tools, the api() abstraction erodes and the project loses its consistent data access path

Create API endpoint or use extractValidObjectId()

// Option 1: Use existing api() pattern case 'get_workspace_language': { const rawId = extractValidObjectId(userId); const user = await api('GET', `/api/users/${rawId}`); return { success: true, language: user.preferredLocale || 'en' }; } // Option 2: Lightweight API endpoint // GET /api/users/:id/locale → { locale: 'en' }
Tool won't silently break on schema changes
Security layers apply to all data access
Single data access path to maintain
Issue 08 of 10

Replace console.log with Structured Logging

Low Priority Low Effort PII Exposure Risk

~10 unstructured console.log calls in production code

The MCP tools log every API call, completion, search filter, and first result using console.log with a [MCP Tools] prefix. In production, these mix with all other stdout output with no way to filter by severity, tool name, or session.

// Currently scattered throughout tools.ts: console.log(`[MCP Tools] handleMCPToolCall: ${name}`, { args, userId, workspaceDb }); console.log(`[MCP Tools] API call: ${method} ${fullPath}`); console.log(`[MCP Tools] API call completed: ${method} ${fullPath}`); console.log('[MCP search_calls] Called with filters:', JSON.stringify(filters)); console.log('[MCP search_calls] First call (most recent):', firstCall);
  • PII exposure risk — full args object (contact names, emails, phone numbers) logged to stdout, potentially captured by log aggregators
  • Noise buries real errors — when a production issue occurs, actual errors are lost in debug output; "API call" + "API call completed" doubles every line
  • Debugging blind spot — no severity levels means you can't filter MCP errors from debug output in monitoring dashboards
  • Log volume cost — 20 tool calls per session × 3+ log lines each adds up in log storage/processing costs

Minimal structured logger with severity

const mcpLog = { debug: (msg: string, meta?: object) => process.env.NODE_ENV !== 'production' && console.log(`[MCP:debug] ${msg}`, meta || ''), info: (msg: string, meta?: object) => console.log(`[MCP:info] ${msg}`, meta || ''), error: (msg: string, meta?: object) => console.error(`[MCP:error] ${msg}`, meta || ''), }; // Usage: mcpLog.debug(`tool call: ${name}`); // dev only mcpLog.error(`API failed: ${status}`, { path }); // always

Remove the "API call completed" log entirely — it adds noise without debugging value.

PII no longer leaked to log aggregators
Production errors visible without noise
Lower log storage costs
Issue 09 of 10

Add Input Validation Beyond ObjectId

Low Priority Medium Effort AI Error Recovery

Malformed LLM input reaches the API layer unchecked

Tool handlers use TypeScript as { ... } casts (compile-time only, zero runtime protection) and forward values directly to the API. LLMs occasionally hallucinate wrong types, generate oversized strings, or pass malformed data. Without MCP-layer validation, these errors surface as cryptic API failures that the LLM can't self-correct from.

// Current: inline type assertion (compile-time only, no runtime check) const { name, company, email } = args as { name: string; // Could be a number at runtime company?: string; // Could be 100KB of text email?: string; // Could be 'not-an-email' };
ScenarioWhat HappensCurrent Protection
LLM passes number where string expectedAPI may crash or store wrong typeNone
100KB description stringStored in DB, inflates responsesNone
Invalid email formatStored as-is, breaks email sending laterNone
Negative limit/offsetUnexpected query behaviorNone
Invalid date stringAPI may crash or ignore filterNone

Note: The API layer has its own validation, so data corruption risk is low. The real impact is user experience — API-level errors return generic messages that the LLM can't act on, while MCP-level validation can tell the LLM exactly what to fix (e.g., "limit must be between 1 and 100").

Zod schemas for tool input validation

Replace inline as { ... } casts with Zod schemas. These provide runtime validation, clear error messages, and can also generate the tool's inputSchema (DRY).

import { z } from 'zod'; const SearchContactsSchema = z.object({ query: z.string().max(500).optional(), city: z.string().max(100).optional(), limit: z.number().int().min(1).max(100).default(10), offset: z.number().int().min(0).default(0), }); // In handler: const parsed = SearchContactsSchema.parse(args); // Throws ZodError with clear message on invalid input
LLM self-corrects from clear validation errors
Users get faster resolution instead of cryptic failures
Prevents oversized payloads hitting the DB
Tool schemas generated from validation code (DRY)
Issue 10 of 10

Fix Pagination: offset vs page Inconsistency

Low Priority Low Effort Broken Feature

LLM cannot paginate past the first page of results

When an LLM asks for "next 10 contacts" by passing offset: 10, the tool still sends page: 1 to the API. The result: the user always sees the same first page of results regardless of the offset they requested. Pagination through MCP tools is effectively broken for several entities.

// search_contacts (line 5369) — page hardcoded despite offset param const result = await api('POST', '/api/contacts/search', { search: query, limit, offset, page: 1, // ← always 1, ignores offset });
ToolAcceptsSends to APICorrect?
search_contactsoffset, limitoffset + page: 1Ambiguous
search_opportunitiesoffset, limitoffset + page: 1Ambiguous
get_tasks_overviewoffset, limitpage: Math.floor(offset/limit)+1Correct
search_callsoffset, limit, pageall threeOver-specified

Pick one pagination model, apply consistently

If the API uses page-based pagination, compute page from offset and limit. The get_tasks_overview handler already does this correctly — apply the same pattern everywhere.

// Standardized pagination helper function toPage(offset: number, limit: number): number { return Math.floor(offset / limit) + 1; } // Usage in all search tools: const result = await api('POST', '/api/contacts/search', { search: query, limit, page: toPage(offset, limit), // offset removed — API uses page });
Users can browse full result sets via AI
LLM can reliably navigate large datasets
Fixes actual broken pagination in production

Улучшения MCP-сервера

Аудит архитектуры и рекомендации по оптимизации
10 проблем выявлено
4 высокий приоритет
3 средний приоритет
3 низкий приоритет
# Проблема Приоритет Трудозатраты Влияние на проект
01 Разделить монолит tools.ts (10K строк) Высокий Средние Изоляция сбоев
02 Устранить HTTP-оверхед при вызовах к себе Высокий Высокие Скорость ответа ИИ
03 Кэшировать системные настройки на сессию Высокий Низкие Снижение нагрузки на БД
04 Исправить непоследовательную обработку ошибок Высокий Низкие Баг в продакшне
05 Утечка памяти в Streamable HTTP сессиях Средний Низкие Стабильность сервера
06 Непоследовательный паттерн подтверждения удаления Средний Низкие Пользовательский опыт
07 get_workspace_language нарушает архитектуру Средний Низкие Риск скрытых поломок
08 Заменить console.log структурированным логированием Низкий Низкие Риск утечки ПД
09 Добавить валидацию ввода помимо ObjectId Низкий Средние Восстановление ИИ после ошибок
10 Исправить несоответствие пагинации offset vs page Низкий Низкие Сломанная функция
Отчёт по улучшениям MCP Kompot — Март 2026
Проблема 01 из 10

Разделить монолит tools.ts на 10 000 строк

Высокий приоритет Средние трудозатраты Изоляция сбоев

Один файл, 10 227 строк, 190 case-блоков

Все определения и обработчики инструментов находятся в одном файле за единственной функцией handleMCPToolCall. Это создаёт конкретные риски для проекта:

  • Отсутствие изоляции сбоев — синтаксическая ошибка или сломанный импорт в любом обработчике не даёт загрузиться всей функции handleMCPToolCall, выводя из строя все 190 инструментов разом
  • Невозможность тестирования — нельзя протестировать отдельный обработчик изолированно; импорт файла загружает все 190 инструментов со всеми зависимостями
  • Медленная сборка — TypeScript перекомпилирует весь файл на 10K строк (с 190 inline type assertion) при изменении любого инструмента, замедляя инкрементальную сборку
  • Нет выборочной композиции — если интеграции нужны только контакты, она всё равно загружает счета, споры, воркфлоу и всё остальное
tools.ts
10 227 строк • 190 case-блоков
определения + обработчики + apiCall + хелперы
Всё в одном файле, один switch statement
lib/mcp/
  server.ts ← без изменений
  api-client.ts ← apiCall() + обёртка api()
  types.ts ← типы ToolDefinition, ToolHandler
  tools/
    index.ts ← реестр: собирает все инструменты
    contacts.ts ← определение + обработчик вместе
    opportunities.ts
    tasks.ts
    invoices.ts
    projects.ts
    jobs.ts
    calls-sms.ts
    ... ← один файл на домен (~15 модулей)
tools/contacts.ts // Определение и обработчик рядом друг с другом export const contactTools: ToolModule = { tools: [ { name: 'search_contacts', description: '...', inputSchema: { ... } }, { name: 'create_contact', description: '...', inputSchema: { ... } }, ], handlers: { search_contacts: async (args, api) => { ... }, create_contact: async (args, api) => { ... }, }, };
Сломанный инструмент не обрушит все 190 остальных
Модульное тестирование по доменам
Быстрая инкрементальная сборка
Композируемые наборы инструментов для интеграций
Параллельная разработка без конфликтов
Проблема 02 из 10

Устранить HTTP-оверхед при вызовах к себе

Высокий приоритет Высокие трудозатраты Скорость ответа ИИ

Каждый MCP-инструмент делает HTTP-запрос к самому себе

Обработчики инструментов вызывают fetch('http://localhost:3000/api/...') для каждой операции — полный HTTP round-trip к функции в том же процессе. Два конкретных последствия для проекта:

  • Медленные ответы ИИ — разговоры с LLM ощущаются заторможенными, каждый вызов добавляет 3-10 мс ненужной задержки; multi-tool вызовы вроде get_contact_details (4 параллельных fetch) накапливают это
  • Нет поддержки транзакций — каждая операция — отдельный HTTP-запрос, невозможно обернуть многошаговую логику (создать счёт + создать товары) в одну транзакцию БД
MCP Handler
JSON.stringify
fetch(localhost)
HTTP Parse
API Route
Controller
MongoDB
4 лишних шага (сериализация + HTTP + парсинг + маршрутизация) на каждый вызов
СценарийHTTP-оверхедВызововПотеряно
Простой поиск~3-5 мс1~5 мс
get_contact_details~3-5 мс × 44 параллельных~15 мс
Создание счёта (авто-товары)~3-5 мс × N1 + N товаров~25 мс
LLM-сессия (20 вызовов)~3-5 мс × 20+20+~80 мс
MCP Handler
Controller
MongoDB
Прямой вызов функции — ноль HTTP-оверхеда

Поэтапная миграция через сервисный слой

Выделить логику контроллеров в импортируемые сервисные функции, которые API-маршруты и MCP-инструменты смогут вызывать напрямую. Это также создаёт переиспользуемый сервисный слой для будущих интеграций (вебхуки, фоновые задачи). Мигрировать по одной сущности, начиная с самых частых инструментов.

До (текущий) const result = await api('POST', '/api/contacts/search', { search: query });
После (прямой вызов) import { searchContacts } from '@/modules/workspace/contact/service'; const result = await searchContacts({ search: query, workspaceDb });
Быстрые ИИ-диалоги для пользователей
Многошаговые операции становятся транзакционными
Переиспользуемый сервисный слой для вебхуков/задач
Лучшая трассировка ошибок в продакшне
Проблема 03 из 10

Кэшировать системные настройки на сессию

Высокий приоритет Низкие трудозатраты Снижение нагрузки на БД

Избыточные запросы к БД при каждом вызове инструмента

Две функции независимо запрашивают одни и те же системные настройки:

  • getEnabledMCPTools() — вызывается при каждом запросе tools/list
  • isToolEnabled() — вызывается при каждом вызове инструмента

Обе вызывают getSystemSettingsInternal(workspaceDb), которая обращается к MongoDB. В типичной LLM-сессии с 20 вызовами это приводит к 20+ избыточным запросам к БД для данных, которые редко меняются.

tools/list
getSystemSettingsInternal() → MongoDB
вызов #1
getSystemSettingsInternal() → MongoDB
вызов #2
getSystemSettingsInternal() → MongoDB
...
один и тот же запрос повторяется N раз
вызов #20
getSystemSettingsInternal() → MongoDB

TTL-кэш внутри createMCPServer()

Кэшировать список включённых инструментов один раз на сессию с TTL 30–60 секунд. Если админ изменит настройку во время сессии, она подхватится в пределах одного TTL-окна.

function createMCPServer(userId, apiToken, workspaceDb) { let cachedEnabled: string[] | null = null; let cacheTime = 0; const TTL = 30_000; async function getEnabled() { if (cachedEnabled && Date.now() - cacheTime < TTL) return cachedEnabled; const settings = await getSystemSettingsInternal(workspaceDb); cachedEnabled = settings.ai?.mcpTools?.enabled ?? null; cacheTime = Date.now(); return cachedEnabled; } // Использовать getEnabled() в обработчиках ListTools и CallTool }
До

20 вызовов = 21 запрос к БД
(1 список + 20 вызовов)

После

20 вызовов = 1 запрос к БД
(кэш на 30 сек)

~20x меньше запросов к БД за MCP-сессию
Снижение нагрузки на MongoDB в продакшне
Быстрее время отклика для пользователей
~15 строк кода — минимальный риск
Проблема 04 из 10

Исправить непоследовательную обработку ошибок

Высокий приоритет Низкие трудозатраты Баг в продакшне

LLM-клиенты молча пропускают ошибки валидации — реальный баг в продакшне

Обработчик CallToolRequestSchema ловит выброшенные ошибки и помечает их isError: true. Однако некоторые инструменты возвращают объекты ошибок вместо выброса. Они доходят до LLM как обычные «успешные» результаты, и LLM может не понять, что операция провалилась, не повторит попытку и не сообщит пользователю.

ПаттернКодОпределяется как ошибка?Используется в
throw Error throw new Error('Not found') Да — ловится в server.ts create_task, get_*_details
return { error } return { error: 'Invalid ID' } Нет — считается успехом create_opportunity
return { success: false } return { success: false, error: '...' } Нет — считается успехом invalidObjectIdError()
Возвращённая ошибка (не распознана)
// server.ts оборачивает как УСПЕХ { content: [{ type: "text", text: '{"error":"Invalid priorityId"}' }], // isError ОТСУТСТВУЕТ — LLM может не распознать сбой }
Выброшенная ошибка (распознана корректно)
// server.ts ловит и помечает как ошибку { content: [{ type: "text", text: "Error: Invalid priorityId" }], isError: true // LLM знает, что нужно повторить или скорректировать }

Стандартизировать — все ошибки через throw

Заменить все return { error: ... } и return { success: false, ... } на throw new Error(...). Блок catch в server.ts:119 уже обрабатывает это корректно.

// До (молча проходит как успех) if (priorityId && !isValidObjectId(priorityId)) return { error: `Invalid priorityId` }; // После (корректно помечается как ошибка) if (priorityId && !isValidObjectId(priorityId)) throw new Error(`Invalid priorityId format. Use search_dictionaries...`);
Пользователи видят корректную обратную связь об ошибках от ИИ
LLM повторяет неудачные операции вместо молчаливого продолжения
Исправляет реальный баг в продакшне
~20 строк изменений — минимальный риск
Проблема 05 из 10

Утечка памяти в Streamable HTTP сессиях

Средний приоритет Низкие трудозатраты Стабильность сервера

Заброшенные MCP-сессии накапливаются до перезапуска сервера

Когда пользователь Claude Desktop закрывает ноутбук, теряет соединение или его браузер падает, MCP-сессия навсегда остаётся в памяти сервера. За дни/недели работы эти осиротевшие сессии накапливаются и потребляют память. Сервер восстанавливается только при перезапуске.

  • SSE-транспорт имеет очистку через res.on('close') + heartbeat — работает корректно
  • Streamable HTTP не имеет аналогичной очистки для заброшенных сессий
  • Каждая осиротевшая сессия удерживает экземпляр Server + Transport + замыкания со ссылками на workspace
SSE-транспорт (есть очистка)
res.on('close', () => { clearInterval(heartbeat); transports.delete(sessionId); sessionTokens.delete(sessionId); sessionWsids.delete(sessionId); });
Streamable HTTP (нет очистки)
transport.onclose = () => { // Срабатывает только при явном close() // НЕ при отключении/падении клиента streamableTransports.delete(id); }; // Нет TTL, нет периодической очистки // Заброшенные сессии живут вечно

TTL-очистка с периодической проверкой

const SESSION_TTL = 30 * 60_000; // 30 минут const sessionLastActive = new Map<string, number>(); // Обновлять при каждом запросе sessionLastActive.set(sessionId, Date.now()); // Периодическая очистка каждые 60 сек setInterval(() => { const now = Date.now(); for (const [id, lastActive] of sessionLastActive) { if (now - lastActive > SESSION_TTL) { streamableTransports.get(id)?.close(); streamableTransports.delete(id); streamableServers.delete(id); sessionLastActive.delete(id); } } }, 60_000);
Сервер остаётся стабильным при длительной работе в продакшне
Нет роста памяти между деплоями
~20 строк кода — минимальный риск
Проблема 06 из 10

Непоследовательный паттерн подтверждения удаления

Средний приоритет Низкие трудозатраты Пользовательский опыт

Непредсказуемое поведение: одни удаления спрашивают дважды, другие выполняются сразу

Пользователи, взаимодействующие через Claude Desktop или другие MCP-клиенты, сталкиваются с непоследовательным поведением: удаление задачи требует двойного подтверждения (два вызова инструмента), тогда как удаление проекта, счёта или контакта происходит мгновенно. Нет чёткого правила, какие сущности «защищены», а какие нет.

ИнструментПодтверждение?Паттерн
delete_taskДапараметр confirmed + предупреждение
delete_disputeДапараметр confirmed + предупреждение
delete_projectНетНемедленное удаление
delete_jobНетНемедленное удаление
delete_fileНетНемедленное удаление
delete_interactionНетНемедленное удаление
delete_kb_articleНетНемедленное удаление
delete_opportunityНетНемедленное удаление
delete_contactНетНемедленное удаление
delete_invoiceНетНемедленное удаление
Вариант А: Подтверждать все удаления

Применить паттерн confirmed ко всем инструментам удаления. Максимальная безопасность, но добавляет один дополнительный round-trip на удаление.

Вариант Б: Убрать все подтверждения

Убрать паттерн confirmed полностью. LLM уже спрашивают пользователей перед деструктивными операциями. Упрощает логику инструментов.

Рекомендация: Вариант Б

LLM-клиенты (Claude, GPT) обучены спрашивать пользователя перед деструктивными вызовами. Параметр confirmed вынуждает делать дополнительный вызов, который замедляет диалог без реальной безопасности — LLM и так подтверждает с пользователем. Убрав его, пользователи получат более быстрый и последовательный опыт.

Единообразный UX для всех сущностей
Быстрые операции удаления для пользователей
Меньше расход токенов за диалог
Проблема 07 из 10

get_workspace_language нарушает архитектуру

Средний приоритет Низкие трудозатраты Риск скрытых поломок

Один инструмент обходит весь слой абстракции api()

Каждый инструмент в системе использует обёртку api() для REST-вызовов. Инструмент get_workspace_language — единственное исключение: он напрямую импортирует Mongoose-модели и делает запрос к базе данных.

// ЕДИНСТВЕННЫЙ инструмент, который так делает: case 'get_workspace_language': { const { connectToDatabase } = await import('@/lib/mongodb'); const { default: UserModel } = await import('@/modules/manager/user/model'); await connectToDatabase(); const rawId = userId.includes(':') ? userId.split(':').pop()! : userId; const mgUser = await UserModel.findById(rawId).select('preferredLocale').lean(); return { success: true, language: mgUser?.preferredLocale || 'en' }; }
  • Скрытая поломка — если схема модели User, логика подключения к БД или слой авторизации изменится, этот инструмент молча сломается в продакшне, т.к. он вне стандартного пути обновлений
  • Обход уровней безопасности — API-маршруты могут иметь контроль доступа, rate limiting или аудит-логирование, которые этот прямой запрос к БД полностью пропускает
  • Дублирование логики — вручную парсит префиксы userId вместо использования существующего хелпера extractValidObjectId(), создавая второе место для поддержки
  • Риск прецедента — если этот паттерн скопируют в другие инструменты, абстракция api() размоется и проект потеряет единый путь доступа к данным

Создать API-эндпоинт или использовать extractValidObjectId()

// Вариант 1: Использовать существующий паттерн api() case 'get_workspace_language': { const rawId = extractValidObjectId(userId); const user = await api('GET', `/api/users/${rawId}`); return { success: true, language: user.preferredLocale || 'en' }; } // Вариант 2: Лёгкий API-эндпоинт // GET /api/users/:id/locale → { locale: 'en' }
Инструмент не сломается молча при изменении схемы
Уровни безопасности применяются ко всему доступу к данным
Единый путь доступа к данным для поддержки
Проблема 08 из 10

Заменить console.log структурированным логированием

Низкий приоритет Низкие трудозатраты Риск утечки ПД

~10 неструктурированных вызовов console.log в production-коде

MCP-инструменты логируют каждый API-вызов, завершение, поисковый фильтр и первый результат через console.log с префиксом [MCP Tools]. В продакшне они смешиваются со всем остальным stdout-выводом без возможности фильтрации по severity, имени инструмента или сессии.

// Разбросано по tools.ts: console.log(`[MCP Tools] handleMCPToolCall: ${name}`, { args, userId, workspaceDb }); console.log(`[MCP Tools] API call: ${method} ${fullPath}`); console.log(`[MCP Tools] API call completed: ${method} ${fullPath}`); console.log('[MCP search_calls] Called with filters:', JSON.stringify(filters)); console.log('[MCP search_calls] First call (most recent):', firstCall);
  • Риск утечки ПД — полный объект args (имена, email, телефоны) логируется в stdout, потенциально попадая в агрегаторы логов
  • Шум скрывает реальные ошибки — при проблемах в продакшне реальные ошибки теряются в отладочном выводе; «API call» + «API call completed» удваивают каждую строку
  • Слепая зона отладки — нет уровней severity, невозможно отфильтровать ошибки MCP от отладочного вывода в мониторинге
  • Объём логов — 20 вызовов за сессию × 3+ строк каждый складываются в стоимость хранения/обработки логов

Минимальный структурированный логгер с уровнями

const mcpLog = { debug: (msg: string, meta?: object) => process.env.NODE_ENV !== 'production' && console.log(`[MCP:debug] ${msg}`, meta || ''), info: (msg: string, meta?: object) => console.log(`[MCP:info] ${msg}`, meta || ''), error: (msg: string, meta?: object) => console.error(`[MCP:error] ${msg}`, meta || ''), }; // Использование: mcpLog.debug(`tool call: ${name}`); // только dev mcpLog.error(`API failed: ${status}`, { path }); // всегда

Убрать лог «API call completed» полностью — он создаёт шум без ценности для отладки.

ПД больше не утекают в агрегаторы логов
Ошибки в продакшне видны без шума
Ниже стоимость хранения логов
Проблема 09 из 10

Добавить валидацию ввода помимо ObjectId

Низкий приоритет Средние трудозатраты Восстановление ИИ после ошибок

Некорректный ввод от LLM попадает на уровень API без проверки

Обработчики инструментов используют TypeScript as { ... } приведения (только на этапе компиляции, ноль защиты в рантайме) и передают значения напрямую в API. LLM иногда галлюцинируют неправильные типы, генерируют слишком длинные строки или передают некорректные данные. Без валидации на уровне MCP эти ошибки проявляются как непонятные сбои API, из которых LLM не может самостоятельно восстановиться.

// Текущее: inline type assertion (только compile-time, нет runtime-проверки) const { name, company, email } = args as { name: string; // Может быть числом в рантайме company?: string; // Может быть 100КБ текста email?: string; // Может быть 'не-email' };
СценарийЧто происходитТекущая защита
LLM передаёт число вместо строкиAPI может упасть или сохранить неверный типНет
Описание на 100КБСохраняется в БД, раздувает ответыНет
Невалидный формат emailСохраняется как есть, ломает отправку email позжеНет
Отрицательный limit/offsetНеожиданное поведение запросаНет
Невалидная строка датыAPI может упасть или проигнорировать фильтрНет

Примечание: Уровень API имеет собственную валидацию, поэтому риск повреждения данных низок. Реальное влияние — на UX: ошибки уровня API возвращают общие сообщения, с которыми LLM не может работать, тогда как валидация MCP может сказать LLM точно, что исправить (напр., «limit должен быть от 1 до 100»).

Zod-схемы для валидации ввода инструментов

Заменить inline as { ... } приведения на Zod-схемы. Они обеспечивают runtime-валидацию, понятные сообщения об ошибках и могут генерировать inputSchema инструмента (DRY).

import { z } from 'zod'; const SearchContactsSchema = z.object({ query: z.string().max(500).optional(), city: z.string().max(100).optional(), limit: z.number().int().min(1).max(100).default(10), offset: z.number().int().min(0).default(0), }); // В обработчике: const parsed = SearchContactsSchema.parse(args); // Бросает ZodError с понятным сообщением при невалидном вводе
LLM самостоятельно исправляется по понятным ошибкам валидации
Пользователи получают быстрое решение вместо непонятных сбоев
Защита от чрезмерных payload в БД
Схемы инструментов генерируются из кода валидации (DRY)
Проблема 10 из 10

Исправить несоответствие пагинации: offset vs page

Низкий приоритет Низкие трудозатраты Сломанная функция

LLM не может перейти дальше первой страницы результатов

Когда LLM запрашивает «следующие 10 контактов» передавая offset: 10, инструмент всё равно отправляет page: 1 в API. Результат: пользователь всегда видит одну и ту же первую страницу вне зависимости от запрошенного offset. Пагинация через MCP-инструменты фактически сломана для нескольких сущностей.

// search_contacts (строка 5369) — page зафиксирована несмотря на параметр offset const result = await api('POST', '/api/contacts/search', { search: query, limit, offset, page: 1, // ← всегда 1, игнорирует offset });
ИнструментПринимаетОтправляет в APIКорректно?
search_contactsoffset, limitoffset + page: 1Неоднозначно
search_opportunitiesoffset, limitoffset + page: 1Неоднозначно
get_tasks_overviewoffset, limitpage: Math.floor(offset/limit)+1Корректно
search_callsoffset, limit, pageall threeИзбыточно

Выбрать одну модель пагинации, применить последовательно

Если API использует постраничную пагинацию, вычислять page из offset и limit. Обработчик get_tasks_overview уже делает это корректно — применить тот же паттерн повсюду.

// Стандартизированный хелпер пагинации function toPage(offset: number, limit: number): number { return Math.floor(offset / limit) + 1; } // Использование во всех search-инструментах: const result = await api('POST', '/api/contacts/search', { search: query, limit, page: toPage(offset, limit), // offset убран — API использует page });
Пользователи могут просматривать полные наборы результатов через ИИ
LLM может надёжно навигировать по большим датасетам
Исправляет реально сломанную пагинацию в продакшне