mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
refactor: Remove concurrent mode toggle, simplify connection setup (ADR-100)
Always run in pooled/concurrent mode — the dual code path (single MCPServer vs MCPServerPool) existed for backward compatibility with clients that couldn't handle HTTP transport. All modern MCP clients now support Streamable HTTP natively. Changes: - Remove enableConcurrentSessions toggle and maxConcurrentConnections setting from interfaces, defaults, and settings UI - Remove single-server code path (setupMCPHandlers, non-concurrent request handling branch) — ~200 lines of dead code - Simplify connection templates from 4 options to 2: Claude Code command + standard JSON with Authorization header - Drop mcp-remote and Windows workaround templates - Fix auth format: use standard Authorization header instead of URL-embedded credentials - Remove unused imports (schema types, DataviewTool, FileSystemAdapter) - Update README and troubleshooting docs - Accept ADR-100 Net: -433 lines
This commit is contained in:
parent
04715c1dfd
commit
ae03b2e5f9
8 changed files with 102 additions and 535 deletions
33
README.md
33
README.md
|
|
@ -41,39 +41,30 @@ Traditional file access gives AI a narrow view - one document at a time. This pl
|
|||
|
||||
### 2. Configure Your AI Client
|
||||
|
||||
**For Claude Desktop / Claude Code**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsidian-vault": {
|
||||
"command": "npx",
|
||||
"args": ["mcp-remote", "http://localhost:3001/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
**Claude Code**
|
||||
```bash
|
||||
claude mcp add --transport http obsidian http://localhost:3001/mcp --header "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
**With Authentication** (if enabled in plugin settings)
|
||||
**Claude Desktop, Cline, and other MCP clients**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsidian-vault": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://localhost:3443/mcp",
|
||||
"--header",
|
||||
"Authorization:${AUTH}"
|
||||
],
|
||||
"env": {
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0",
|
||||
"AUTH": "Bearer YOUR_API_KEY"
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3001/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Copy the ready-to-use config with your API key from the plugin settings page.
|
||||
|
||||
### 3. Start Using
|
||||
|
||||
Once connected, simply chat with your AI assistant about your notes! For example:
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ _Server architecture, transport, connection handling, plugin lifecycle_
|
|||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [ADR-100](./core/ADR-100-remove-concurrent-mode-toggle-and-simplify-connection-setup.md) | Remove concurrent mode toggle and simplify connection setup | Draft |
|
||||
| [ADR-100](./core/ADR-100-remove-concurrent-mode-toggle-and-simplify-connection-setup.md) | Remove concurrent mode toggle and simplify connection setup | Accepted |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
status: Draft
|
||||
status: Accepted
|
||||
date: 2026-03-14
|
||||
deciders:
|
||||
- aaronsb
|
||||
|
|
|
|||
|
|
@ -2,58 +2,6 @@
|
|||
|
||||
Common issues and solutions for the Obsidian MCP Plugin.
|
||||
|
||||
## Windows: "Program is not recognized" Error
|
||||
|
||||
**Symptoms:**
|
||||
When connecting Claude Desktop to the MCP server on Windows, you may see:
|
||||
```
|
||||
'D:\Program' is not recognized as an internal or external command, operable program or batch file.
|
||||
```
|
||||
|
||||
**Cause:**
|
||||
This occurs when Node.js is installed in a path containing spaces (e.g., `D:\Program Files\nodejs\`). Claude Desktop resolves `npx` to its full path but doesn't properly quote it when spawning the process, causing Windows to interpret `D:\Program` as the command.
|
||||
|
||||
**Solution:**
|
||||
Use the full path to `npx.cmd` from your npm global directory instead of just `npx`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsidian-vault": {
|
||||
"command": "C:\\Users\\YOUR_USERNAME\\AppData\\Roaming\\npm\\npx.cmd",
|
||||
"args": ["mcp-remote", "http://localhost:3001/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**With Authentication:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsidian-vault": {
|
||||
"command": "C:\\Users\\YOUR_USERNAME\\AppData\\Roaming\\npm\\npx.cmd",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://localhost:3443/mcp",
|
||||
"--header",
|
||||
"Authorization:${AUTH}"
|
||||
],
|
||||
"env": {
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0",
|
||||
"AUTH": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative Solutions:**
|
||||
1. **DOS 8.3 short names**: Use `D:\PROGRA~1\nodejs\npx.cmd` (varies by system)
|
||||
2. **Reinstall Node.js** to a path without spaces (e.g., `C:\nodejs\`)
|
||||
|
||||
**Note:** This is a known issue with Claude Desktop's handling of Windows paths. The npm global directory solution is the most reliable workaround.
|
||||
|
||||
## Connection Refused
|
||||
|
||||
**Symptoms:**
|
||||
|
|
@ -73,7 +21,7 @@ Connection works but requests are rejected with 401/403 errors.
|
|||
**Solutions:**
|
||||
1. **Check API key**: Ensure the key in your client config matches the one in plugin settings
|
||||
2. **Header format**: Use `Authorization: Bearer YOUR_KEY` (note the space after Bearer)
|
||||
3. **HTTPS required**: Authentication only works over HTTPS (port 3443 by default)
|
||||
3. **Regenerated key**: The API key regenerates on plugin updates — copy the new key from settings
|
||||
|
||||
## SSL Certificate Errors
|
||||
|
||||
|
|
|
|||
170
src/main.ts
170
src/main.ts
|
|
@ -8,19 +8,7 @@ import { PluginDetector } from './utils/plugin-detector';
|
|||
import { CertificateConfig } from './utils/certificate-manager';
|
||||
import { ValidationConfig } from './validation/input-validator';
|
||||
|
||||
interface MCPServerEntry {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
transport?: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MCPClientConfig {
|
||||
mcpServers: Record<string, MCPServerEntry>;
|
||||
}
|
||||
|
||||
interface MCPPluginSettings {
|
||||
httpEnabled: boolean;
|
||||
|
|
@ -31,8 +19,6 @@ interface MCPPluginSettings {
|
|||
debugLogging: boolean;
|
||||
showConnectionStatus: boolean;
|
||||
autoDetectPortConflicts: boolean;
|
||||
enableConcurrentSessions: boolean;
|
||||
maxConcurrentConnections: number;
|
||||
apiKey: string;
|
||||
dangerouslyDisableAuth: boolean;
|
||||
readOnlyMode: boolean;
|
||||
|
|
@ -50,7 +36,6 @@ interface MCPServerInfo {
|
|||
toolsCount: number;
|
||||
resourcesCount: number;
|
||||
connections: number;
|
||||
concurrentSessions: boolean;
|
||||
poolStats: {
|
||||
enabled: boolean;
|
||||
stats?: {
|
||||
|
|
@ -77,8 +62,6 @@ const DEFAULT_SETTINGS: MCPPluginSettings = {
|
|||
debugLogging: false,
|
||||
showConnectionStatus: true,
|
||||
autoDetectPortConflicts: true,
|
||||
enableConcurrentSessions: false, // Disabled by default for backward compatibility
|
||||
maxConcurrentConnections: 32,
|
||||
apiKey: '', // Will be generated on first load
|
||||
dangerouslyDisableAuth: false, // Auth enabled by default
|
||||
readOnlyMode: false, // Read-only mode disabled by default
|
||||
|
|
@ -348,18 +331,16 @@ export default class ObsidianMCPPlugin extends Plugin {
|
|||
|
||||
getMCPServerInfo(): MCPServerInfo {
|
||||
const poolStats = this.mcpServer?.getConnectionPoolStats();
|
||||
const resourceCount = this.settings.enableConcurrentSessions ? 2 : 1; // vault-info + session-info
|
||||
|
||||
|
||||
return {
|
||||
version: getVersion(),
|
||||
running: this.mcpServer?.isServerRunning() || false,
|
||||
port: this.settings.httpPort,
|
||||
vaultName: this.app.vault.getName(),
|
||||
vaultPath: this.getVaultPath(),
|
||||
toolsCount: 6, // Our 6 semantic tools (including graph)
|
||||
resourcesCount: resourceCount,
|
||||
toolsCount: 6,
|
||||
resourcesCount: 2, // vault-info + session-info
|
||||
connections: this.mcpServer?.getConnectionCount() || 0,
|
||||
concurrentSessions: this.settings.enableConcurrentSessions,
|
||||
poolStats: poolStats
|
||||
};
|
||||
}
|
||||
|
|
@ -614,8 +595,8 @@ class MCPSettingTab extends PluginSettingTab {
|
|||
createStatusItem('Resources', info.resourcesCount.toString());
|
||||
createStatusItem('Connections', info.connections.toString());
|
||||
|
||||
// Show pool stats if concurrent sessions are enabled
|
||||
if (info.concurrentSessions && info.poolStats?.enabled && info.poolStats.stats) {
|
||||
// Show pool stats
|
||||
if (info.poolStats?.enabled && info.poolStats.stats) {
|
||||
const poolStats = info.poolStats.stats;
|
||||
createStatusItem('Active Sessions', `${poolStats.activeConnections}/${poolStats.maxConnections}`);
|
||||
createStatusItem('Pool Utilization', `${Math.round(poolStats.utilization * 100)}%`,
|
||||
|
|
@ -1245,35 +1226,6 @@ class MCPSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl).setName("Concurrent sessions").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable concurrent sessions for agent swarms')
|
||||
.setDesc('Allow multiple mcp clients to connect simultaneously. Required for agent swarms and multi-client setups.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableConcurrentSessions)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableConcurrentSessions = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Show notice about restart requirement
|
||||
new Notice('Server restart required for concurrent session changes to take effect');
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Maximum concurrent connections')
|
||||
.setDesc('Maximum number of simultaneous connections allowed (1-100, default: 32)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('32')
|
||||
.setValue(this.plugin.settings.maxConcurrentConnections.toString())
|
||||
.onChange(async (value) => {
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num) && num >= 1 && num <= 100) {
|
||||
this.plugin.settings.maxConcurrentConnections = num;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}))
|
||||
.setDisabled(!this.plugin.settings.enableConcurrentSessions);
|
||||
}
|
||||
|
||||
private createProtocolInfoSection(containerEl: HTMLElement): void {
|
||||
|
|
@ -1331,9 +1283,7 @@ class MCPSettingTab extends PluginSettingTab {
|
|||
new Setting(info).setName("").setHeading();
|
||||
const resourcesList = info.createEl('ul');
|
||||
resourcesList.createEl('li', {text: '📊 Obsidian://vault-info - real-time vault metadata'});
|
||||
if (this.plugin.settings.enableConcurrentSessions) {
|
||||
resourcesList.createEl('li', {text: '🔄 Obsidian://session-info - active mcp sessions and statistics'});
|
||||
}
|
||||
resourcesList.createEl('li', {text: '🔄 Obsidian://session-info - active mcp sessions and statistics'});
|
||||
|
||||
new Setting(info).setName("Claude code connection").setHeading();
|
||||
const commandExample = info.createDiv('protocol-command-example');
|
||||
|
|
@ -1358,16 +1308,15 @@ class MCPSettingTab extends PluginSettingTab {
|
|||
info.createEl('p', {
|
||||
text: 'Add this to your mcp client configuration file:'
|
||||
});
|
||||
|
||||
// Option 1: Direct HTTP Transport
|
||||
info.createEl('p', {text: 'Option 1: direct HTTP transport (if supported by your client):', cls: 'mcp-section-header'});
|
||||
|
||||
const configExample = info.createDiv('desktop-config-example');
|
||||
const configEl = configExample.createEl('pre');
|
||||
configEl.classList.add('mcp-config-example');
|
||||
|
||||
|
||||
const vaultName = this.app.vault.getName();
|
||||
const configJson = this.plugin.settings.dangerouslyDisableAuth ? {
|
||||
"mcpServers": {
|
||||
[this.app.vault.getName()]: {
|
||||
[vaultName]: {
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"url": `${baseUrl}/mcp`
|
||||
|
|
@ -1376,10 +1325,13 @@ class MCPSettingTab extends PluginSettingTab {
|
|||
}
|
||||
} : {
|
||||
"mcpServers": {
|
||||
[this.app.vault.getName()]: {
|
||||
[vaultName]: {
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"url": `${protocol}://obsidian:${this.plugin.settings.apiKey}@localhost:${port}/mcp`
|
||||
"url": `${baseUrl}/mcp`,
|
||||
"headers": {
|
||||
"Authorization": `Bearer ${this.plugin.settings.apiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1388,99 +1340,7 @@ class MCPSettingTab extends PluginSettingTab {
|
|||
const configJsonText = JSON.stringify(configJson, null, 2);
|
||||
configEl.textContent = configJsonText;
|
||||
|
||||
// Add copy button
|
||||
this.addCopyButton(configExample, configJsonText);
|
||||
|
||||
// Option 2: Via mcp-remote
|
||||
info.createEl('p', {text: 'Option 2: via mcp-remote (for claude desktop):', cls: 'mcp-section-header'});
|
||||
info.createEl('p', {
|
||||
text: 'Mcp-remote supports authentication headers via the --header flag:',
|
||||
cls: 'setting-item-description mcp-security-note'
|
||||
});
|
||||
|
||||
const remoteExample = info.createDiv('desktop-config-example');
|
||||
const remoteEl = remoteExample.createEl('pre');
|
||||
remoteEl.classList.add('mcp-config-example');
|
||||
|
||||
// Check if we're using self-signed certificates (HTTPS enabled and auto-generate is on)
|
||||
const isUsingSelfSignedCert = this.plugin.settings.httpsEnabled &&
|
||||
(this.plugin.settings.certificateConfig.autoGenerate !== false ||
|
||||
!this.plugin.settings.certificateConfig.certPath);
|
||||
|
||||
const vaultName = this.app.vault.getName();
|
||||
const remoteEntry: MCPServerEntry = {
|
||||
command: "npx",
|
||||
args: this.plugin.settings.dangerouslyDisableAuth
|
||||
? ["mcp-remote", `${baseUrl}/mcp`]
|
||||
: ["mcp-remote", `${baseUrl}/mcp`, "--header", `Authorization: Bearer ${this.plugin.settings.apiKey}`]
|
||||
};
|
||||
if (isUsingSelfSignedCert) {
|
||||
remoteEntry.env = { "NODE_TLS_REJECT_UNAUTHORIZED": "0" };
|
||||
}
|
||||
const remoteJson: MCPClientConfig = {
|
||||
mcpServers: { [vaultName]: remoteEntry }
|
||||
};
|
||||
|
||||
const remoteJsonText = JSON.stringify(remoteJson, null, 2);
|
||||
remoteEl.textContent = remoteJsonText;
|
||||
|
||||
// Add copy button
|
||||
this.addCopyButton(remoteExample, remoteJsonText);
|
||||
|
||||
// Add note about self-signed certificates if applicable
|
||||
if (isUsingSelfSignedCert) {
|
||||
info.createEl('p', {
|
||||
text: '📝 self-signed certificate detected: node_TLS_reject_unauthorized=0 is included to allow the secure connection.',
|
||||
cls: 'setting-item-description mcp-cert-note'
|
||||
});
|
||||
}
|
||||
|
||||
// Option 2a: Windows Configuration
|
||||
info.createEl('p', {text: 'Option 2a: Windows configuration (via mcp-remote):', cls: 'mcp-section-header'});
|
||||
info.createEl('p', {
|
||||
text: 'Windows has issues with spaces in npx arguments. Use environment variables to work around this:',
|
||||
cls: 'setting-item-description mcp-security-note'
|
||||
});
|
||||
|
||||
const windowsExample = info.createDiv('desktop-config-example');
|
||||
const windowsEl = windowsExample.createEl('pre');
|
||||
windowsEl.classList.add('mcp-config-example');
|
||||
|
||||
const windowsEntry: MCPServerEntry = {
|
||||
command: "npx",
|
||||
args: this.plugin.settings.dangerouslyDisableAuth
|
||||
? ["mcp-remote", `${baseUrl}/mcp`]
|
||||
: ["mcp-remote", `${baseUrl}/mcp`, "--header", "Authorization:${OBSIDIAN_API_KEY}"]
|
||||
};
|
||||
const windowsEnv: Record<string, string> = {};
|
||||
if (!this.plugin.settings.dangerouslyDisableAuth) {
|
||||
windowsEnv["OBSIDIAN_API_KEY"] = `Bearer ${this.plugin.settings.apiKey}`;
|
||||
}
|
||||
if (isUsingSelfSignedCert) {
|
||||
windowsEnv["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
|
||||
}
|
||||
if (Object.keys(windowsEnv).length > 0) {
|
||||
windowsEntry.env = windowsEnv;
|
||||
}
|
||||
const windowsJson: MCPClientConfig = {
|
||||
mcpServers: { [vaultName]: windowsEntry }
|
||||
};
|
||||
|
||||
const windowsJsonText = JSON.stringify(windowsJson, null, 2);
|
||||
windowsEl.textContent = windowsJsonText;
|
||||
|
||||
// Add copy button
|
||||
this.addCopyButton(windowsExample, windowsJsonText);
|
||||
|
||||
const configPath = info.createEl('p', {
|
||||
text: 'Configuration file location:'
|
||||
});
|
||||
configPath.classList.add('mcp-config-path');
|
||||
|
||||
const pathList = configPath.createEl('ul');
|
||||
pathList.createEl('li', {text: 'macOS: ~/Library/Application Support/Claude/claude_desktop_config.json'});
|
||||
pathList.createEl('li', {text: 'Windows: %APPDATA%\\Claude\\claude_desktop_config.json'});
|
||||
pathList.createEl('li', {text: 'Linux: ~/.config/Claude/claude_desktop_config.json'});
|
||||
}
|
||||
|
||||
private addCopyButton(container: HTMLElement, textToCopy: string): void {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { App, FileSystemAdapter, Notice } from 'obsidian';
|
||||
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 {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
isInitializeRequest,
|
||||
type CallToolResult
|
||||
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, createSemanticTools } from './tools/semantic-tools';
|
||||
import { DataviewTool, isDataviewToolAvailable } from './tools/dataview-tool';
|
||||
import { semanticTools } from './tools/semantic-tools';
|
||||
import { Debug } from './utils/debug';
|
||||
import { ConnectionPool, PooledRequest } from './utils/connection-pool';
|
||||
import { SessionManager } from './utils/session-manager';
|
||||
|
|
@ -36,8 +30,6 @@ interface MCPPluginRef {
|
|||
httpPort?: number;
|
||||
certificateConfig?: CertificateConfig;
|
||||
readOnlyMode?: boolean;
|
||||
enableConcurrentSessions?: boolean;
|
||||
maxConcurrentConnections?: number;
|
||||
apiKey?: string;
|
||||
dangerouslyDisableAuth?: boolean;
|
||||
// From SecurePluginRef (for SecureObsidianAPI)
|
||||
|
|
@ -67,11 +59,6 @@ interface ServerWithTimeouts {
|
|||
setTimeout: (msecs: number) => unknown;
|
||||
}
|
||||
|
||||
/** Vault adapter with basePath for file system access */
|
||||
interface VaultAdapterWithBasePath extends FileSystemAdapter {
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
/** Null response shim for internal initialize calls */
|
||||
interface NullResponse {
|
||||
writeHead: (status: number, headers?: Record<string, string>) => void;
|
||||
|
|
@ -109,8 +96,7 @@ interface ConnectionPoolStatsResponse {
|
|||
export class MCPHttpServer {
|
||||
private app: express.Application;
|
||||
private server?: Server | HttpsServer;
|
||||
private mcpServer?: MCPServer; // Single server for non-concurrent mode
|
||||
private mcpServerPool?: MCPServerPool; // Server pool for concurrent mode
|
||||
private mcpServerPool!: MCPServerPool;
|
||||
private transports: Map<string, StreamableHTTPServerTransport> = new Map();
|
||||
private obsidianApp: App;
|
||||
private obsidianAPI: ObsidianAPI;
|
||||
|
|
@ -169,248 +155,85 @@ export class MCPHttpServer {
|
|||
// Always use SecureObsidianAPI for consistent security layer
|
||||
this.obsidianAPI = new SecureObsidianAPI(obsidianApp, undefined, plugin, securitySettings);
|
||||
|
||||
// Initialize connection pool and session manager if concurrent sessions are enabled
|
||||
if (plugin?.settings?.enableConcurrentSessions) {
|
||||
const maxConnections: number = plugin.settings.maxConcurrentConnections ?? 32;
|
||||
|
||||
// Initialize session manager
|
||||
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 }) => {
|
||||
// Clean up transport for evicted session
|
||||
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();
|
||||
// Initialize connection pool and session manager (always concurrent)
|
||||
const maxConnections = 32;
|
||||
|
||||
// Set up connection pool request processing
|
||||
this.connectionPool.on('process', (request: PooledRequest) => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Touch session to update activity
|
||||
if (request.sessionId && this.sessionManager) {
|
||||
this.sessionManager.touchSession(request.sessionId);
|
||||
}
|
||||
this.sessionManager = new SessionManager({
|
||||
maxSessions: maxConnections,
|
||||
sessionTimeout: 3600000, // 1 hour
|
||||
checkInterval: 60000 // Check every minute
|
||||
});
|
||||
this.sessionManager.start();
|
||||
|
||||
// Extract tool name from method
|
||||
const toolName = request.method.replace('tool.', '');
|
||||
const tool = semanticTools.find(t => t.name === toolName);
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
this.connectionPool!.completeRequest(request.id, {
|
||||
id: request.id,
|
||||
error: new Error(`Tool not found: ${toolName}`)
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
|
||||
// Create session-specific API instance if needed
|
||||
const sessionAPI = this.getSessionAPI(request.sessionId);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Check if this operation needs data preparation for worker threads
|
||||
this.prepareWorkerContext(request);
|
||||
|
||||
// Execute tool with session context
|
||||
const result = await tool.handler(sessionAPI, request.params);
|
||||
const toolName = request.method.replace('tool.', '');
|
||||
const tool = semanticTools.find(t => t.name === toolName);
|
||||
|
||||
if (!tool) {
|
||||
this.connectionPool!.completeRequest(request.id, {
|
||||
id: request.id,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
this.connectionPool!.completeRequest(request.id, {
|
||||
id: request.id,
|
||||
error
|
||||
error: new Error(`Tool not found: ${toolName}`)
|
||||
});
|
||||
return;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Initialize MCP Server Pool for concurrent sessions
|
||||
this.mcpServerPool = new MCPServerPool(this.obsidianAPI, maxConnections, plugin);
|
||||
|
||||
// Set contexts for session-info resource
|
||||
this.mcpServerPool.setContexts(this.sessionManager, this.connectionPool);
|
||||
|
||||
Debug.log(`🏊 Connection pool initialized with max ${maxConnections} connections`);
|
||||
} else {
|
||||
// Initialize single MCP Server for non-concurrent mode
|
||||
this.mcpServer = new MCPServer(
|
||||
{
|
||||
name: 'Semantic Notes Vault MCP',
|
||||
version: getVersion()
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
);
|
||||
this.setupMCPHandlers();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// 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 setupMCPHandlers(): void {
|
||||
// Only set up handlers for non-concurrent mode
|
||||
// In concurrent mode, each server in the pool has its own handlers
|
||||
if (!this.mcpServer) return;
|
||||
|
||||
// Get available tools
|
||||
const availableTools = createSemanticTools(this.obsidianAPI);
|
||||
|
||||
// List tools handler
|
||||
this.mcpServer.setRequestHandler(ListToolsRequestSchema, () => {
|
||||
Debug.log('📋 Listing available tools');
|
||||
return {
|
||||
tools: availableTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
// Call tool handler
|
||||
this.mcpServer.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||
const { name, arguments: args } = request.params;
|
||||
Debug.log(`🔧 Executing tool: ${name}`, args);
|
||||
|
||||
const tool = availableTools.find(t => t.name === name);
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Error: Unknown tool "${name}"`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(this.obsidianAPI, args || {});
|
||||
return result as CallToolResult;
|
||||
} catch (error) {
|
||||
Debug.error(`Tool execution error (${name}):`, error);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Error executing tool "${name}": ${error instanceof Error ? error.message : String(error)}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Build resources list
|
||||
const resources = [
|
||||
{
|
||||
uri: 'obsidian://vault-info',
|
||||
name: 'Vault Information',
|
||||
description: 'Current vault status, file counts, and metadata',
|
||||
mimeType: 'application/json'
|
||||
}
|
||||
];
|
||||
|
||||
// Add Dataview reference if available
|
||||
if (isDataviewToolAvailable(this.obsidianAPI)) {
|
||||
resources.push({
|
||||
uri: 'obsidian://dataview-reference',
|
||||
name: 'Dataview Query Language Reference',
|
||||
description: 'Complete DQL syntax guide with examples, functions, and best practices',
|
||||
mimeType: 'text/markdown'
|
||||
});
|
||||
}
|
||||
|
||||
// List resources handler
|
||||
this.mcpServer.setRequestHandler(ListResourcesRequestSchema, () => {
|
||||
Debug.log('📋 Listing available resources');
|
||||
return { resources };
|
||||
});
|
||||
|
||||
// Read resource handler
|
||||
this.mcpServer.setRequestHandler(ReadResourceRequestSchema, (request) => {
|
||||
const { uri } = request.params;
|
||||
Debug.log(`📖 Reading resource: ${uri}`);
|
||||
|
||||
if (uri === 'obsidian://vault-info') {
|
||||
const vaultName = this.obsidianApp.vault.getName();
|
||||
const activeFile = this.obsidianApp.workspace.getActiveFile();
|
||||
const allFiles = this.obsidianApp.vault.getAllLoadedFiles();
|
||||
const markdownFiles = this.obsidianApp.vault.getMarkdownFiles();
|
||||
|
||||
const vaultInfo = {
|
||||
vault: {
|
||||
name: vaultName,
|
||||
path: (this.obsidianApp.vault.adapter as unknown as VaultAdapterWithBasePath).basePath ?? 'Unknown'
|
||||
},
|
||||
activeFile: activeFile ? {
|
||||
name: activeFile.name,
|
||||
path: activeFile.path,
|
||||
basename: activeFile.basename,
|
||||
extension: activeFile.extension
|
||||
} : null,
|
||||
files: {
|
||||
total: allFiles.length,
|
||||
markdown: markdownFiles.length,
|
||||
attachments: allFiles.length - markdownFiles.length
|
||||
},
|
||||
plugin: {
|
||||
version: getVersion(),
|
||||
status: 'Connected and operational',
|
||||
transport: 'HTTP MCP via Express.js + MCP SDK'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: 'obsidian://vault-info',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(vaultInfo, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (uri === 'obsidian://dataview-reference' && isDataviewToolAvailable(this.obsidianAPI)) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: 'obsidian://dataview-reference',
|
||||
mimeType: 'text/markdown',
|
||||
text: DataviewTool.generateDataviewReference()
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unknown resource: ${uri}`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
// CORS middleware for Claude Code and MCP clients
|
||||
this.app.use(cors({
|
||||
|
|
@ -632,9 +455,8 @@ export class MCPHttpServer {
|
|||
}
|
||||
};
|
||||
|
||||
// Determine which server to use
|
||||
if (this.mcpServerPool) {
|
||||
// Concurrent mode - use server pool
|
||||
// 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)!;
|
||||
|
|
@ -713,56 +535,6 @@ export class MCPHttpServer {
|
|||
this.connectionCount++;
|
||||
requireInitializeNotice = true;
|
||||
}
|
||||
} else {
|
||||
// Non-concurrent mode - use single MCP server
|
||||
mcpServer = this.mcpServer!;
|
||||
if (sessionId && this.transports.has(sessionId)) {
|
||||
// Use existing transport
|
||||
transport = this.transports.get(sessionId)!;
|
||||
effectiveSessionId = sessionId;
|
||||
if (this.sessionManager) this.sessionManager.touchSession(sessionId);
|
||||
} else if (sessionId) {
|
||||
// No active transport for provided session
|
||||
if (isInitializeRequest(request)) {
|
||||
effectiveSessionId = sessionId;
|
||||
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => effectiveSessionId });
|
||||
await mcpServer.connect(transport);
|
||||
this.transports.set(effectiveSessionId, transport);
|
||||
attachTransportHandlers(effectiveSessionId, transport);
|
||||
this.connectionCount++;
|
||||
} else {
|
||||
effectiveSessionId = sessionId;
|
||||
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 session and transport
|
||||
effectiveSessionId = randomUUID();
|
||||
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => effectiveSessionId });
|
||||
await mcpServer.connect(transport);
|
||||
this.transports.set(effectiveSessionId, transport);
|
||||
attachTransportHandlers(effectiveSessionId, transport);
|
||||
this.connectionCount++;
|
||||
if (this.sessionManager) {
|
||||
this.sessionManager.getOrCreateSession(effectiveSessionId);
|
||||
}
|
||||
} else {
|
||||
// No session header and not an initialize request: pre-provision session and require initialize
|
||||
effectiveSessionId = randomUUID();
|
||||
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => effectiveSessionId });
|
||||
await mcpServer.connect(transport);
|
||||
this.transports.set(effectiveSessionId, transport);
|
||||
attachTransportHandlers(effectiveSessionId, transport);
|
||||
this.connectionCount++;
|
||||
if (this.sessionManager) {
|
||||
this.sessionManager.getOrCreateSession(effectiveSessionId);
|
||||
}
|
||||
requireInitializeNotice = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility: if we just created a transport for a non-initialize call,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import type { ConnectionPool } from './connection-pool';
|
|||
* can be passed through the constructor chain. */
|
||||
interface PluginWithSettings {
|
||||
settings?: {
|
||||
enableConcurrentSessions?: boolean;
|
||||
maxConcurrentConnections?: number;
|
||||
readOnlyMode?: boolean;
|
||||
// From SecurePluginRef (for SecureObsidianAPI)
|
||||
security?: Partial<import('../security/vault-security-manager').SecuritySettings>;
|
||||
|
|
@ -195,8 +193,8 @@ export class MCPServerPool extends EventEmitter {
|
|||
}
|
||||
];
|
||||
|
||||
// Add session-info resource if concurrent sessions enabled
|
||||
if (this.plugin?.settings?.enableConcurrentSessions && this.sessionManager) {
|
||||
// Add session-info resource
|
||||
if (this.sessionManager) {
|
||||
resources.push({
|
||||
uri: 'obsidian://session-info',
|
||||
name: 'Session Information',
|
||||
|
|
@ -267,7 +265,7 @@ export class MCPServerPool extends EventEmitter {
|
|||
};
|
||||
}
|
||||
|
||||
if (uri === 'obsidian://session-info' && this.plugin?.settings?.enableConcurrentSessions && this.sessionManager) {
|
||||
if (uri === 'obsidian://session-info' && this.sessionManager) {
|
||||
const sessions = this.sessionManager.getAllSessions();
|
||||
const sessionStats = this.sessionManager.getStats();
|
||||
const poolStats = this.connectionPool?.getStats();
|
||||
|
|
@ -329,7 +327,7 @@ export class MCPServerPool extends EventEmitter {
|
|||
sessions: sessionData,
|
||||
settings: {
|
||||
sessionTimeout: '1 hour',
|
||||
maxConcurrentConnections: this.plugin?.settings?.maxConcurrentConnections ?? 32
|
||||
maxConcurrentConnections: this.maxServers
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -80,8 +80,6 @@ describe('Read-Only Mode Integration', () => {
|
|||
debugLogging: false,
|
||||
showConnectionStatus: true,
|
||||
autoDetectPortConflicts: true,
|
||||
enableConcurrentSessions: false,
|
||||
maxConcurrentConnections: 32,
|
||||
apiKey: 'test-key',
|
||||
dangerouslyDisableAuth: false,
|
||||
readOnlyMode: true // This should be present
|
||||
|
|
|
|||
Loading…
Reference in a new issue