aaronsb_obsidian-mcp-plugin/src/mcp-server.ts
Aaron Bockelie 535dfd1c53 fix(mcp): stop debug route shadowing the GET /mcp SSE stream + two-row Levenshtein
Reimplemented from PR #126 (djsplice, who also reported #125), reduced to
the parts that are verified-clean. The worker-offload half of #126 is split
out to a tracking issue (inert as wired + correctness divergences — see PR
discussion / the follow-up issue).

SSE route deconfliction
  In @modelcontextprotocol/sdk@1.29.0 (the pinned version), `GET /mcp` is
  the standalone server->client SSE stream for server-initiated messages —
  verified in webStandardStreamableHttp.js:handleGetRequest (opens
  `_GET_stream`, Content-Type text/event-stream, gated on
  Accept: text/event-stream + session + protocol version). A debug route
  returning application/json on `GET /mcp` shadowed it, so any client that
  opens the standalone stream (mcp-remote-class bridges — #128's logs show
  one in use here) received JSON instead of an SSE stream and treated it as
  failed. Moved debug to `GET /mcp-info`; `GET /mcp` and `POST /mcp` both
  reach handleMCPRequest. Unlike #126's `app.all('/mcp')`, GET/POST are
  registered individually so the existing explicit `app.delete('/mcp')`
  session-close handler is preserved.

  Scope note: this restores the server-initiated-notification channel (a
  real latent defect on its own). It is NOT claimed to fix #128 — per the
  #190 investigation that is a separate SDK-1.29 compat-init problem — and
  #125's "SSE reconnection loop" is the reporter's un-reproduced diagnosis.

Two-row Levenshtein
  fuzzy-match.ts rewritten from a full (m+1)x(n+1) matrix to a two-row
  formulation (O(n) memory, no per-line array-of-arrays allocation) plus
  length heuristics and a 0.95 early-exit. Pure CPU/memory win on the
  main-thread edit.window path; behaviour-preserving. Adds the first
  fuzzy-match tests (classic Levenshtein distances, symmetry,
  heuristic-preservation).

build.yml: PR-status-comment step set continue-on-error so it cannot red an
otherwise-green build on fork/limited-token PRs.

Co-authored-by: djsplice <barrows.jeff@gmail.com>
2026-05-18 21:05:22 -05:00

856 lines
No EOL
31 KiB
TypeScript

import express from 'express';
import cors from 'cors';
import { App, Notice } from 'obsidian';
import { createServer as createHttpServer, Server, IncomingMessage, ServerResponse } from 'http';
import { Server as HttpsServer } from 'https';
import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
isInitializeRequest
} from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import * as path from 'path';
import { getVersion } from './version';
import { ObsidianAPI } from './utils/obsidian-api';
import { SecureObsidianAPI, VaultSecurityManager } from './security';
import { semanticTools } from './tools/semantic-tools';
import { Debug } from './utils/debug';
import { ConnectionPool, PooledRequest } from './utils/connection-pool';
import { SessionManager } from './utils/session-manager';
import { MCPServerPool } from './utils/mcp-server-pool';
import { CertificateManager, CertificateConfig } from './utils/certificate-manager';
/** Minimal plugin interface for MCPHttpServer.
* Includes fields from SecurePluginRef and ObsidianAPIPluginRef so the same object
* can be passed through the constructor chain. */
interface MCPPluginRef {
settings?: {
httpsEnabled?: boolean;
httpsPort?: number;
httpPort?: number;
certificateConfig?: CertificateConfig;
readOnlyMode?: boolean;
apiKey?: string;
dangerouslyDisableAuth?: boolean;
// From SecurePluginRef (for SecureObsidianAPI)
security?: Partial<import('./security/vault-security-manager').SecuritySettings>;
// From ObsidianAPIPluginRef (for ObsidianAPI)
validation?: Partial<import('./validation/input-validator').ValidationConfig>;
};
manifest: { dir?: string };
// From ObsidianAPIPluginRef
ignoreManager?: import('./security/mcp-ignore-manager').MCPIgnoreManager;
mcpServer?: { isServerRunning(): boolean; getConnectionCount(): number };
}
/** JSON-RPC request body structure */
interface JsonRpcRequest {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: Record<string, unknown>;
}
/** Server with configurable timeout properties (Node.js http.Server internals) */
interface ServerWithTimeouts {
keepAliveTimeout: number;
headersTimeout: number;
requestTimeout: number;
setTimeout: (msecs: number) => unknown;
}
/** Null response shim for internal initialize calls */
interface NullResponse {
writeHead: (status: number, headers?: Record<string, string>) => void;
setHeader: (k: string, v: string) => void;
getHeader: (k: string) => string | undefined;
statusCode: number;
headersSent: boolean;
write: (chunk: unknown) => void;
end: (data?: unknown) => void;
on: (event: string, handler: (...args: unknown[]) => void) => void;
once: (event: string, handler: (...args: unknown[]) => void) => void;
status: (v: number) => NullResponse;
json: (body: unknown) => void;
send: (body?: unknown) => void;
}
/** Connection pool stats response */
interface ConnectionPoolStatsResponse {
enabled: boolean;
stats?: {
activeConnections: number;
queuedRequests: number;
maxConnections: number;
utilization: number;
};
serverPoolStats?: {
activeServers: number;
maxServers: number;
utilization: string;
totalRequests: number;
};
}
export class MCPHttpServer {
private app: express.Application;
private server?: Server | HttpsServer;
private mcpServerPool!: MCPServerPool;
private transports: Map<string, StreamableHTTPServerTransport> = new Map();
private obsidianApp: App;
private obsidianAPI: ObsidianAPI;
private port: number;
private isRunning: boolean = false;
private connectionCount: number = 0;
private plugin?: MCPPluginRef; // Reference to the plugin
private connectionPool?: ConnectionPool;
private sessionManager?: SessionManager;
private certificateManager: CertificateManager | null;
private isHttps: boolean = false;
constructor(obsidianApp: App, port: number = 3001, plugin?: MCPPluginRef) {
this.obsidianApp = obsidianApp;
this.port = port;
this.plugin = plugin;
// Only initialize certificate manager if HTTPS is enabled
// to avoid fs module issues in browser environment
if (plugin?.settings?.httpsEnabled && plugin?.settings?.certificateConfig?.enabled) {
this.isHttps = true;
this.port = plugin.settings.httpsPort ?? 3443;
// Lazy initialize certificate manager only when needed
this.certificateManager = null; // Will be initialized when server starts
} else {
this.certificateManager = null;
}
// Always use SecureObsidianAPI with VaultSecurityManager as our firewall
Debug.log('🔐 Initializing VaultSecurityManager firewall');
// Configure security rules based on mode
let securitySettings;
if (plugin?.settings?.readOnlyMode) {
Debug.log('🔒 READ-ONLY MODE ACTIVATED - Loading restrictive ruleset');
securitySettings = VaultSecurityManager.presets.readOnly();
} else {
Debug.log('✅ READ-ONLY MODE DEACTIVATED - Loading permissive ruleset');
// Minimal security - just path validation and .mcpignore blocking
securitySettings = {
pathValidation: 'strict' as const, // Always validate paths for security
permissions: {
read: true,
create: true,
update: true,
delete: true,
move: true,
rename: true,
execute: true
},
blockedPaths: [], // .mcpignore will handle blocking
logSecurityEvents: false
};
}
// Always use SecureObsidianAPI for consistent security layer
this.obsidianAPI = new SecureObsidianAPI(obsidianApp, undefined, plugin, securitySettings);
// Initialize connection pool and session manager (always concurrent)
const maxConnections = 32;
this.sessionManager = new SessionManager({
maxSessions: maxConnections,
sessionTimeout: 3600000, // 1 hour
checkInterval: 60000 // Check every minute
});
this.sessionManager.start();
// Handle session events
this.sessionManager.on('session-evicted', (data: { session: { sessionId: string }; reason: string }) => {
const transport = this.transports.get(data.session.sessionId);
if (transport) {
void transport.close();
this.transports.delete(data.session.sessionId);
this.connectionCount = Math.max(0, this.connectionCount - 1);
Debug.log(`🔚 Evicted session ${data.session.sessionId} (${data.reason}). Connections: ${this.connectionCount}`);
}
});
// Initialize connection pool
this.connectionPool = new ConnectionPool({
maxConnections,
maxQueueSize: 100,
requestTimeout: 30000,
sessionTimeout: 3600000,
sessionCheckInterval: 60000,
workerScript: path.join(plugin?.manifest.dir ?? '', 'dist', 'workers', 'semantic-worker.js')
});
void this.connectionPool.initialize();
// Set up connection pool request processing
this.connectionPool.on('process', (request: PooledRequest) => {
void (async () => {
try {
if (request.sessionId && this.sessionManager) {
this.sessionManager.touchSession(request.sessionId);
}
const toolName = request.method.replace('tool.', '');
const tool = semanticTools.find(t => t.name === toolName);
if (!tool) {
this.connectionPool!.completeRequest(request.id, {
id: request.id,
error: new Error(`Tool not found: ${toolName}`)
});
return;
}
const sessionAPI = this.getSessionAPI(request.sessionId);
this.prepareWorkerContext(request);
const result = await tool.handler(sessionAPI, request.params);
this.connectionPool!.completeRequest(request.id, {
id: request.id,
result
});
} catch (error) {
this.connectionPool!.completeRequest(request.id, {
id: request.id,
error
});
}
})();
});
// Initialize MCP Server Pool
this.mcpServerPool = new MCPServerPool(this.obsidianAPI, maxConnections, plugin);
this.mcpServerPool.setContexts(this.sessionManager, this.connectionPool);
Debug.log(`🏊 Connection pool initialized with max ${maxConnections} connections`);
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
private setupMiddleware(): void {
// CORS middleware for Claude Code and MCP clients
this.app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'Mcp-Session-Id'],
exposedHeaders: ['Mcp-Session-Id']
}));
// JSON body parser
this.app.use(express.json());
// Request logging for debugging (moved before auth to see all requests)
this.app.use((req, res, next) => {
Debug.log(`📡 ${req.method} ${req.url}`, {
headers: req.headers,
body: req.body ? JSON.stringify(req.body).substring(0, 200) : ''
});
next();
});
// Authentication middleware - check API key
this.app.use((req, res, next) => {
// Skip auth for OPTIONS requests (CORS preflight)
if (req.method === 'OPTIONS') {
return next();
}
// Check if auth is disabled
if (this.plugin?.settings?.dangerouslyDisableAuth === true) {
Debug.log('⚠️ Authentication is DISABLED - allowing access without credentials');
return next();
}
const apiKey = this.plugin?.settings?.apiKey;
if (!apiKey) {
// No API key configured, allow access (backward compatibility)
Debug.log('🔓 No API key configured, allowing access');
return next();
}
// Check Authorization header for Bearer or Basic Auth
const authHeader = req.headers.authorization;
Debug.log(`🔐 Auth check - Header present: ${!!authHeader}, API key set: ${!!apiKey}`);
if (!authHeader) {
Debug.log('❌ Auth failed: Missing Authorization header');
res.status(401).json({ error: 'Authentication required' });
return;
}
let authenticated = false;
// Check for Bearer token
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7);
authenticated = (token === apiKey);
Debug.log(`🔐 Bearer auth - Token matches: ${authenticated}`);
}
// Check for Basic auth
else if (authHeader.startsWith('Basic ')) {
const base64Credentials = authHeader.slice(6);
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
const [username, password] = credentials.split(':');
authenticated = (password === apiKey);
Debug.log(`🔐 Basic auth - Username: ${username}, Password matches: ${authenticated}`);
} else {
Debug.log('❌ Auth failed: Invalid Authorization header format');
}
if (!authenticated) {
Debug.log('❌ Auth failed: Invalid API key');
res.status(401).json({ error: 'Invalid API key' });
return;
}
Debug.log('✅ Auth successful');
next();
});
}
private setupRoutes(): void {
// Health check endpoint
this.app.get('/', (req, res) => {
const response = {
name: 'Semantic Notes Vault MCP',
version: getVersion(),
status: 'running',
vault: this.obsidianApp.vault.getName(),
timestamp: new Date().toISOString()
};
Debug.log('📊 Health check requested');
res.json(response);
});
// MCP discovery endpoints
this.app.get('/.well-known/appspecific/com.mcp.obsidian-mcp', (req, res) => {
const isHttps = this.plugin?.settings?.httpsEnabled === true;
const protocol = isHttps ? 'https' : 'http';
res.json({
endpoint: `${protocol}://localhost:${this.port}/mcp`,
protocol: protocol,
method: 'POST',
contentType: 'application/json'
});
});
// Debug/info endpoint — moved off `GET /mcp` so it no longer shadows the
// SSE stream the client opens with `GET /mcp` (the shadowing caused the
// SSE reconnection loop in #125).
this.app.get('/mcp-info', (req, res) => {
res.json({
message: 'MCP endpoint active',
usage: 'POST /mcp for messages, GET /mcp for the SSE stream',
protocol: 'Model Context Protocol',
transport: 'HTTP',
sessionHeader: 'Mcp-Session-Id'
});
});
// MCP protocol endpoint — StreamableHTTPServerTransport. POST carries
// messages, GET establishes the SSE stream; both go to the same handler.
// DELETE keeps its own explicit session-close handler below, so we route
// GET/POST individually rather than `app.all` (which would shadow it).
this.app.post('/mcp', (req, res) => {
void this.handleMCPRequest(req, res);
});
this.app.get('/mcp', (req, res) => {
void this.handleMCPRequest(req, res);
});
// Handle session deletion
this.app.delete('/mcp', (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
if (sessionId && this.transports.has(sessionId)) {
const transport = this.transports.get(sessionId)!;
void transport.close();
this.transports.delete(sessionId);
this.connectionCount = Math.max(0, this.connectionCount - 1);
Debug.log(`🔚 Closed MCP session: ${sessionId} (Remaining: ${this.connectionCount})`);
res.status(200).json({ message: 'Session closed' });
} else {
res.status(404).json({ error: 'Session not found' });
}
});
}
private async handleMCPRequest(req: express.Request, res: express.Response): Promise<void> {
try {
const request = req.body as JsonRpcRequest | undefined;
// Get or create session ID
const sessionId = req.headers['mcp-session-id'] as string | undefined;
Debug.log(`📨 MCP Request: ${request?.method ?? 'unknown'}${sessionId ? ` [Session: ${sessionId}]` : ''}`, request?.params);
// Quick path: lightweight ping to keep session alive
if (request?.method === 'session/ping' || request?.method === 'status/ping') {
if (sessionId && this.sessionManager) {
this.sessionManager.touchSession(sessionId);
}
if (sessionId) {
res.setHeader('Mcp-Session-Id', sessionId);
}
res.status(200).json({ jsonrpc: '2.0', id: request?.id ?? null, result: { ok: true, sessionId: sessionId || null } });
return;
}
let transport: StreamableHTTPServerTransport | undefined;
let effectiveSessionId!: string; // will be set in the branches below
if (sessionId) {
effectiveSessionId = sessionId;
}
let mcpServer: MCPServer;
// Helper: create a null response shim so we can send an internal initialize
const createNullRes = (): NullResponse => {
const headers: Record<string, string> = {};
let sent = false;
let code = 200;
const resp: NullResponse = {
// Node-style response interface (minimal no-op implementation)
writeHead: (_status: number, _headers?: Record<string, string>) => { code = _status; sent = true; },
setHeader: (k: string, v: string) => { headers[k] = v; },
getHeader: (k: string) => headers[k],
get statusCode() { return code; },
set statusCode(v: number) { code = v; },
get headersSent() { return sent; },
write: (_chunk: unknown) => { /* no-op */ },
end: (_data?: unknown) => { sent = true; },
on: (_event: string, _handler: (...args: unknown[]) => void) => { /* no-op */ },
once: (_event: string, _handler: (...args: unknown[]) => void) => { /* no-op */ },
// Express-like helpers (used by some wrappers)
status: (v: number) => { code = v; return resp; },
json: (_body: unknown) => { sent = true; },
send: (_body?: unknown) => { sent = true; },
};
return resp;
};
// When a non-initialize request arrives without an active transport,
// we return a JSON-RPC error instructing the client to initialize using
// the provided session ID. This avoids fragile internal auto-initialize.
let requireInitializeNotice = false;
// Helper: register transport with lifecycle hooks
const attachTransportHandlers = (sessId: string, tr: StreamableHTTPServerTransport) => {
try {
// Transport may optionally support EventEmitter API
const emitter = tr as unknown as { on?: (event: string, handler: (...args: unknown[]) => void) => void };
if (typeof emitter.on === 'function') {
emitter.on('close', () => {
if (this.transports.has(sessId)) {
this.transports.delete(sessId);
this.connectionCount = Math.max(0, this.connectionCount - 1);
Debug.log(`🔌 Transport closed for session ${sessId}. Connections: ${this.connectionCount}`);
}
});
emitter.on('error', (e: unknown) => {
Debug.error(`Transport error for session ${sessId}:`, e);
if (this.transports.has(sessId)) {
this.transports.delete(sessId);
this.connectionCount = Math.max(0, this.connectionCount - 1);
}
});
}
} catch {
// Transport may not support event emitters, which is fine
}
};
// Determine which server to use from the pool
if (sessionId && this.transports.has(sessionId)) {
// Use existing transport for this session
transport = this.transports.get(sessionId)!;
// Get the server for this session (it should already exist)
mcpServer = this.mcpServerPool.getOrCreateServer(sessionId);
// Update session activity
if (this.sessionManager) {
this.sessionManager.touchSession(sessionId);
}
} else if (sessionId && this.sessionManager) {
// Session ID provided but no active transport
// Only allow re-create on initialize; otherwise signal explicit session expiration
if (isInitializeRequest(request)) {
const session = this.sessionManager.getOrCreateSession(sessionId);
mcpServer = this.mcpServerPool.getOrCreateServer(sessionId);
effectiveSessionId = sessionId;
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => effectiveSessionId
});
await mcpServer.connect(transport);
this.transports.set(effectiveSessionId, transport);
attachTransportHandlers(effectiveSessionId, transport);
this.connectionCount++;
Debug.log(`♻️ Recreated transport for session ${sessionId} (requests: ${session.requestCount})`);
} else {
// Create transport and require client initialize on next call
const newSessionId = sessionId; // reuse provided id (guaranteed by branch)
mcpServer = this.mcpServerPool.getOrCreateServer(newSessionId);
effectiveSessionId = newSessionId;
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => effectiveSessionId
});
await mcpServer.connect(transport);
this.transports.set(effectiveSessionId, transport);
attachTransportHandlers(effectiveSessionId, transport);
this.connectionCount++;
requireInitializeNotice = true;
}
} else if (!sessionId && isInitializeRequest(request)) {
// New initialization request - create new transport with session
effectiveSessionId = randomUUID();
// Get or create server for this session
mcpServer = this.mcpServerPool.getOrCreateServer(effectiveSessionId);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => effectiveSessionId
});
// Connect the MCP server to this transport
await mcpServer.connect(transport);
// Store the transport for future requests
this.transports.set(effectiveSessionId, transport);
attachTransportHandlers(effectiveSessionId, transport);
this.connectionCount++;
// Register session with manager if enabled
if (this.sessionManager) {
this.sessionManager.getOrCreateSession(effectiveSessionId);
}
} else {
// No or unknown session on non-initialize request.
// Generate a session (or reuse provided) and require client initialize next.
const newSessionId = sessionId ?? randomUUID();
mcpServer = this.mcpServerPool.getOrCreateServer(newSessionId);
effectiveSessionId = newSessionId;
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => effectiveSessionId
});
await mcpServer.connect(transport);
this.transports.set(effectiveSessionId, transport);
attachTransportHandlers(effectiveSessionId, transport);
this.connectionCount++;
requireInitializeNotice = true;
}
// Compatibility: if we just created a transport for a non-initialize call,
// attempt a safe, internal initialize to avoid client retry loops.
if (requireInitializeNotice && transport && !isInitializeRequest(request)) {
// Clone req to ensure session header is present for compat initialize
const compatReq = {
...req,
headers: {
...req.headers,
'mcp-session-id': effectiveSessionId
}
} as unknown as IncomingMessage;
const versionsToTry = ['2025-06-18', '2024-11-05', '1.0'];
let initOk = false;
for (const ver of versionsToTry) {
try {
const initReq = {
jsonrpc: '2.0',
id: '__compat_init__',
method: 'initialize',
params: {
protocolVersion: ver,
capabilities: {},
clientInfo: { name: 'obsidian-mcp-compat', version: getVersion() }
}
} as unknown;
await transport.handleRequest(compatReq, createNullRes() as unknown as ServerResponse, initReq);
initOk = true;
Debug.log(`Compat initialize succeeded with protocolVersion=${ver}`);
break;
} catch (e) {
Debug.error(`Compat initialize attempt failed (protocolVersion=${ver}):`, e);
}
}
// Fail-open: even if initialize failed, proceed with the original request
// to avoid client retry loops. The transport/server may still reject it,
// but this gives us a concrete server error to act on.
requireInitializeNotice = false;
if (!initOk) {
Debug.log('Compat initialize failed; proceeding without explicit initialize (fail-open).');
}
}
// If initialization is still required and this isn't an initialize request,
// instruct the client to initialize for this session.
if (requireInitializeNotice && !isInitializeRequest(request)) {
const id = request?.id ?? null;
res.setHeader('Mcp-Session-Id', effectiveSessionId);
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Server not initialized',
data: { sessionId: effectiveSessionId }
},
id
});
return;
}
// Safety: ensure we have a transport before forwarding
if (!transport) {
const id = request?.id ?? null;
if (effectiveSessionId) {
res.setHeader('Mcp-Session-Id', effectiveSessionId);
}
res.status(200).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'No active transport/session. Please initialize and retry.', data: effectiveSessionId ? { sessionId: effectiveSessionId } : undefined },
id
});
return;
}
// Handle the request using the transport
await transport.handleRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse,
request as unknown
);
Debug.log('📤 MCP Response sent via transport');
} catch (error) {
Debug.error('❌ MCP request error:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal error: ' + (error instanceof Error ? error.message : 'Unknown error')
},
id: null
});
}
}
}
async start(): Promise<void> {
if (this.isRunning) {
Debug.log(`MCP server already running on port ${this.port}`);
return;
}
return new Promise<void>((resolve, reject) => {
// Create HTTP or HTTPS server based on configuration
const certificateConfig: CertificateConfig = this.plugin?.settings?.certificateConfig ?? { enabled: false };
// Initialize certificate manager lazily if HTTPS is enabled
if (this.isHttps && !this.certificateManager) {
try {
this.certificateManager = new CertificateManager(this.obsidianApp);
} catch (error) {
Debug.error('Failed to initialize certificate manager:', error);
// Fall back to HTTP if certificate manager fails
this.isHttps = false;
}
}
// Create server - use certificate manager if available and HTTPS is enabled
if (this.isHttps && this.certificateManager) {
this.server = this.certificateManager.createServer(this.app, certificateConfig, this.port);
} else {
// Create standard HTTP server
this.server = createHttpServer(this.app);
}
const protocol = this.isHttps ? 'https' : 'http';
if (!this.server) {
reject(new Error('Failed to create server'));
return;
}
// Configure server timeouts to keep connections healthy and prevent hangs
try {
const serverWithTimeouts = this.server as unknown as ServerWithTimeouts;
// Keep connections alive long enough for clients, but not indefinitely
serverWithTimeouts.keepAliveTimeout = 60_000; // 60s
// Headers timeout should exceed keepAliveTimeout slightly
serverWithTimeouts.headersTimeout = 65_000; // 65s
// Per-request timeout; 0 to disable, or a generous value
serverWithTimeouts.requestTimeout = 120_000; // 120s
// Legacy idle timeout fallback
if (typeof serverWithTimeouts.setTimeout === 'function') {
serverWithTimeouts.setTimeout(120_000);
}
Debug.log('⏱️ Server timeouts configured (keepAlive=60s, headers=65s, request=120s)');
} catch (e) {
Debug.error('Failed to configure server timeouts:', e);
}
this.server.listen(this.port, () => {
this.isRunning = true;
Debug.log(`🚀 MCP server started on ${protocol}://localhost:${this.port}`);
Debug.log(`📍 Health check: ${protocol}://localhost:${this.port}/`);
Debug.log(`🔗 MCP endpoint: ${protocol}://localhost:${this.port}/mcp`);
if (this.isHttps) {
Debug.log('🔒 HTTPS enabled with certificate');
new Notice(`MCP server running on HTTPS port ${this.port}`);
}
resolve();
});
this.server.on('error', (error: unknown) => {
this.isRunning = false;
Debug.error('❌ Failed to start MCP server:', error);
reject(error instanceof Error ? error : new Error(String(error)));
});
});
}
async stop(): Promise<void> {
if (!this.isRunning || !this.server) {
return;
}
// Clean up all active transports
for (const [sessionId, transport] of this.transports) {
void transport.close();
Debug.log(`🔚 Closed MCP session on shutdown: ${sessionId}`);
}
this.transports.clear();
this.connectionCount = 0; // Reset connection count on server stop
// Shutdown session manager if it exists
if (this.sessionManager) {
this.sessionManager.stop();
}
// Shutdown connection pool if it exists
if (this.connectionPool) {
await this.connectionPool.shutdown();
}
// Shutdown MCP server pool if it exists
if (this.mcpServerPool) {
this.mcpServerPool.shutdown();
}
return new Promise<void>((resolve) => {
this.server?.close(() => {
this.isRunning = false;
Debug.log('👋 MCP server stopped');
resolve();
});
});
}
getPort(): number {
return this.port;
}
isServerRunning(): boolean {
return this.isRunning;
}
getConnectionCount(): number {
return this.connectionCount;
}
/**
* Get connection pool statistics
*/
getConnectionPoolStats(): ConnectionPoolStatsResponse {
if (!this.connectionPool) {
return { enabled: false };
}
const result: ConnectionPoolStatsResponse = {
enabled: true,
stats: this.connectionPool.getStats()
};
// Include MCP server pool stats if available
if (this.mcpServerPool) {
const poolStats = this.mcpServerPool.getStats();
result.serverPoolStats = {
activeServers: poolStats.activeServers,
maxServers: poolStats.maxServers,
utilization: poolStats.utilization,
totalRequests: poolStats.totalRequests
};
}
return result;
}
/**
* Get or create a session-specific API instance
*/
private getSessionAPI(sessionId?: string): ObsidianAPI {
if (!sessionId) {
return this.obsidianAPI;
}
// For now, return the same API instance
// In the future, we could create session-specific instances with isolated state
return this.obsidianAPI;
}
/**
* Prepare context data for worker thread operations
*/
private prepareWorkerContext(request: PooledRequest): unknown {
// Only prepare context for worker-compatible operations
const workerOps = [
'tool.vault.search',
'tool.vault.fragments',
'tool.graph.search-traverse',
'tool.graph.advanced-traverse'
];
if (!workerOps.some(op => request.method.includes(op))) {
return undefined;
}
Debug.log(`📦 Preparing worker context for ${request.method}`);
// For search operations, we might need to pre-fetch file contents
if (request.method.includes('vault.search')) {
// This would be implemented based on the specific needs
// For now, return undefined to use main thread
return undefined;
}
// For graph operations, we need file contents and link graph
if (request.method.includes('graph.search-traverse')) {
try {
const params = request.params as Record<string, unknown> | undefined;
const startPath = typeof params?.startPath === 'string' ? params.startPath : undefined;
if (!startPath) return undefined;
// Get initial file and its links
const file = this.obsidianApp.vault.getAbstractFileByPath(startPath);
if (!file || !('extension' in file)) return undefined;
// This would need more sophisticated pre-fetching logic
// For now, return undefined to use main thread
return undefined;
} catch (error) {
Debug.error('Failed to prepare worker context:', error);
return undefined;
}
}
return undefined;
}
}