Release v1.2.0: Enhanced Authentication & Parent Folder Detection
Phase 1.5 Complete: - Add automatic API key generation with secure random generation - Add createParents parameter to create_note tool - Fix authentication vulnerability (auth enabled without key) - Add MCP client configuration snippet generator - Improve UI/UX for authentication management - Add comprehensive test coverage Security: - Fixed critical vulnerability in authentication middleware - Implement three-layer defense (UI, server start, middleware) - Cryptographically secure key generation (32 chars) Features: - Auto-generate API key when authentication enabled - Copy/regenerate buttons for API key management - Recursive parent folder creation for nested paths - Enhanced error messages with actionable guidance - Selectable connection information and config snippets Documentation: - Updated CHANGELOG.md with v1.2.0 release notes - Updated ROADMAP.md (Phase 1.5 marked complete) - Created IMPLEMENTATION_NOTES_AUTH.md - Created RELEASE_NOTES_v1.2.0.md
This commit is contained in:
@@ -69,6 +69,12 @@ export default class MCPServerPlugin extends Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate authentication configuration
|
||||
if (this.settings.enableAuth && (!this.settings.apiKey || this.settings.apiKey.trim() === '')) {
|
||||
new Notice('⚠️ Cannot start server: Authentication is enabled but no API key is set. Please set an API key in settings or disable authentication.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.mcpServer = new MCPServer(this.app, this.settings);
|
||||
await this.mcpServer.start();
|
||||
|
||||
@@ -28,8 +28,13 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat
|
||||
}
|
||||
|
||||
// Authentication middleware
|
||||
if (settings.enableAuth && settings.apiKey) {
|
||||
if (settings.enableAuth) {
|
||||
app.use((req: Request, res: Response, next: any) => {
|
||||
// Defensive check: if auth is enabled but no API key is set, reject all requests
|
||||
if (!settings.apiKey || settings.apiKey.trim() === '') {
|
||||
return res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Server misconfigured: Authentication enabled but no API key set'));
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const apiKey = authHeader?.replace('Bearer ', '');
|
||||
|
||||
|
||||
150
src/settings.ts
150
src/settings.ts
@@ -1,6 +1,7 @@
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { MCPPluginSettings } from './types/settings-types';
|
||||
import MCPServerPlugin from './main';
|
||||
import { generateApiKey } from './utils/auth-utils';
|
||||
|
||||
export class MCPServerSettingTab extends PluginSettingTab {
|
||||
plugin: MCPServerPlugin;
|
||||
@@ -50,24 +51,30 @@ export class MCPServerSettingTab extends PluginSettingTab {
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
this.plugin.settings.port = port;
|
||||
await this.plugin.saveSettings();
|
||||
if (this.plugin.mcpServer?.isRunning()) {
|
||||
new Notice('⚠️ Server restart required for port changes to take effect');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// CORS setting
|
||||
new Setting(containerEl)
|
||||
.setName('Enable CORS')
|
||||
.setDesc('Enable Cross-Origin Resource Sharing')
|
||||
.setDesc('Enable Cross-Origin Resource Sharing (requires restart)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableCORS)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableCORS = value;
|
||||
await this.plugin.saveSettings();
|
||||
if (this.plugin.mcpServer?.isRunning()) {
|
||||
new Notice('⚠️ Server restart required for CORS changes to take effect');
|
||||
}
|
||||
}));
|
||||
|
||||
// Allowed origins
|
||||
new Setting(containerEl)
|
||||
.setName('Allowed origins')
|
||||
.setDesc('Comma-separated list of allowed origins (* for all)')
|
||||
.setDesc('Comma-separated list of allowed origins (* for all, requires restart)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('*')
|
||||
.setValue(this.plugin.settings.allowedOrigins.join(', '))
|
||||
@@ -77,30 +84,135 @@ export class MCPServerSettingTab extends PluginSettingTab {
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
await this.plugin.saveSettings();
|
||||
if (this.plugin.mcpServer?.isRunning()) {
|
||||
new Notice('⚠️ Server restart required for origin changes to take effect');
|
||||
}
|
||||
}));
|
||||
|
||||
// Authentication
|
||||
new Setting(containerEl)
|
||||
.setName('Enable authentication')
|
||||
.setDesc('Require API key for requests')
|
||||
.setDesc('Require API key for requests (requires restart)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableAuth)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableAuth = value;
|
||||
|
||||
// Auto-generate API key when enabling authentication
|
||||
if (value && (!this.plugin.settings.apiKey || this.plugin.settings.apiKey.trim() === '')) {
|
||||
this.plugin.settings.apiKey = generateApiKey();
|
||||
new Notice('✅ API key generated automatically');
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
if (this.plugin.mcpServer?.isRunning()) {
|
||||
new Notice('⚠️ Server restart required for authentication changes to take effect');
|
||||
}
|
||||
|
||||
// Refresh the display to show the new key
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// API Key
|
||||
new Setting(containerEl)
|
||||
.setName('API Key')
|
||||
.setDesc('API key for authentication (Bearer token)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter API key')
|
||||
.setValue(this.plugin.settings.apiKey || '')
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.apiKey = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
// API Key Display (only show if authentication is enabled)
|
||||
if (this.plugin.settings.enableAuth) {
|
||||
new Setting(containerEl)
|
||||
.setName('API Key Management')
|
||||
.setDesc('Use this key in the Authorization header as Bearer token');
|
||||
|
||||
// Create a full-width container for buttons and key display
|
||||
const apiKeyContainer = containerEl.createDiv({cls: 'mcp-api-key-section'});
|
||||
apiKeyContainer.style.marginBottom = '20px';
|
||||
apiKeyContainer.style.marginLeft = '0';
|
||||
|
||||
// Create button container
|
||||
const buttonContainer = apiKeyContainer.createDiv({cls: 'mcp-api-key-buttons'});
|
||||
buttonContainer.style.display = 'flex';
|
||||
buttonContainer.style.gap = '8px';
|
||||
buttonContainer.style.marginBottom = '12px';
|
||||
|
||||
// Copy button
|
||||
const copyButton = buttonContainer.createEl('button', {text: '📋 Copy Key'});
|
||||
copyButton.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(this.plugin.settings.apiKey || '');
|
||||
new Notice('✅ API key copied to clipboard');
|
||||
});
|
||||
|
||||
// Regenerate button
|
||||
const regenButton = buttonContainer.createEl('button', {text: '🔄 Regenerate Key'});
|
||||
regenButton.addEventListener('click', async () => {
|
||||
this.plugin.settings.apiKey = generateApiKey();
|
||||
await this.plugin.saveSettings();
|
||||
new Notice('✅ New API key generated');
|
||||
if (this.plugin.mcpServer?.isRunning()) {
|
||||
new Notice('⚠️ Server restart required for API key changes to take effect');
|
||||
}
|
||||
this.display();
|
||||
});
|
||||
|
||||
// API Key display (static, copyable text)
|
||||
const keyDisplayContainer = apiKeyContainer.createDiv({cls: 'mcp-api-key-display'});
|
||||
keyDisplayContainer.style.padding = '12px';
|
||||
keyDisplayContainer.style.backgroundColor = 'var(--background-secondary)';
|
||||
keyDisplayContainer.style.borderRadius = '4px';
|
||||
keyDisplayContainer.style.fontFamily = 'monospace';
|
||||
keyDisplayContainer.style.fontSize = '0.9em';
|
||||
keyDisplayContainer.style.wordBreak = 'break-all';
|
||||
keyDisplayContainer.style.userSelect = 'all';
|
||||
keyDisplayContainer.style.cursor = 'text';
|
||||
keyDisplayContainer.style.marginBottom = '16px';
|
||||
keyDisplayContainer.textContent = this.plugin.settings.apiKey || '';
|
||||
}
|
||||
|
||||
// MCP Client Configuration (show always, regardless of auth)
|
||||
containerEl.createEl('h3', {text: 'MCP Client Configuration'});
|
||||
|
||||
const configContainer = containerEl.createDiv({cls: 'mcp-config-snippet'});
|
||||
configContainer.style.marginBottom = '20px';
|
||||
|
||||
const configDesc = configContainer.createEl('p', {
|
||||
text: 'Add this configuration to your MCP client (e.g., Claude Desktop, Cline):'
|
||||
});
|
||||
configDesc.style.marginBottom = '8px';
|
||||
configDesc.style.fontSize = '0.9em';
|
||||
configDesc.style.color = 'var(--text-muted)';
|
||||
|
||||
// Generate JSON config based on auth settings
|
||||
const mcpConfig: any = {
|
||||
"mcpServers": {
|
||||
"obsidian-mcp": {
|
||||
"serverUrl": `http://127.0.0.1:${this.plugin.settings.port}/mcp`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Only add headers if authentication is enabled
|
||||
if (this.plugin.settings.enableAuth && this.plugin.settings.apiKey) {
|
||||
mcpConfig.mcpServers["obsidian-mcp"].headers = {
|
||||
"Authorization": `Bearer ${this.plugin.settings.apiKey}`
|
||||
};
|
||||
}
|
||||
|
||||
// Config display with copy button
|
||||
const configButtonContainer = configContainer.createDiv();
|
||||
configButtonContainer.style.display = 'flex';
|
||||
configButtonContainer.style.gap = '8px';
|
||||
configButtonContainer.style.marginBottom = '8px';
|
||||
|
||||
const copyConfigButton = configButtonContainer.createEl('button', {text: '📋 Copy Configuration'});
|
||||
copyConfigButton.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(mcpConfig, null, 2));
|
||||
new Notice('✅ Configuration copied to clipboard');
|
||||
});
|
||||
|
||||
const configDisplay = configContainer.createEl('pre');
|
||||
configDisplay.style.padding = '12px';
|
||||
configDisplay.style.backgroundColor = 'var(--background-secondary)';
|
||||
configDisplay.style.borderRadius = '4px';
|
||||
configDisplay.style.fontSize = '0.85em';
|
||||
configDisplay.style.overflowX = 'auto';
|
||||
configDisplay.style.userSelect = 'text';
|
||||
configDisplay.style.cursor = 'text';
|
||||
configDisplay.textContent = JSON.stringify(mcpConfig, null, 2);
|
||||
|
||||
// Server status
|
||||
containerEl.createEl('h3', {text: 'Server Status'});
|
||||
@@ -144,10 +256,14 @@ export class MCPServerSettingTab extends PluginSettingTab {
|
||||
|
||||
const infoEl = containerEl.createEl('div', {cls: 'mcp-connection-info'});
|
||||
infoEl.createEl('p', {text: 'MCP Endpoint:'});
|
||||
infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/mcp`});
|
||||
const mcpEndpoint = infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/mcp`});
|
||||
mcpEndpoint.style.userSelect = 'all';
|
||||
mcpEndpoint.style.cursor = 'text';
|
||||
|
||||
infoEl.createEl('p', {text: 'Health Check:'});
|
||||
infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/health`});
|
||||
const healthEndpoint = infoEl.createEl('code', {text: `http://127.0.0.1:${this.plugin.settings.port}/health`});
|
||||
healthEndpoint.style.userSelect = 'all';
|
||||
healthEndpoint.style.cursor = 'text';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,21 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "create_note",
|
||||
description: "Create a new file in the Obsidian vault. Use this to create a new note or file. The parent folder must already exist - this will NOT auto-create folders. Path must be vault-relative with file extension. Will fail if the file already exists. Use list_notes() to verify the parent folder exists before creating.",
|
||||
description: "Create a new file in the Obsidian vault. Use this to create a new note or file. By default, parent folders must already exist. Set createParents to true to automatically create missing parent folders. Path must be vault-relative with file extension. Will fail if the file already exists. Use list_notes() to verify the parent folder exists before creating.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path for the new file (e.g., 'folder/note.md' or 'projects/2024/report.md'). Must include file extension. Parent folders must exist. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
description: "Vault-relative path for the new file (e.g., 'folder/note.md' or 'projects/2024/report.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The complete content to write to the new file. Can include markdown formatting, frontmatter, etc."
|
||||
},
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "If true, automatically create missing parent folders. If false (default), returns an error if parent folders don't exist. Default: false"
|
||||
}
|
||||
},
|
||||
required: ["path", "content"]
|
||||
@@ -122,7 +126,7 @@ export class ToolRegistry {
|
||||
case "read_note":
|
||||
return await this.noteTools.readNote(args.path);
|
||||
case "create_note":
|
||||
return await this.noteTools.createNote(args.path, args.content);
|
||||
return await this.noteTools.createNote(args.path, args.content, args.createParents ?? false);
|
||||
case "update_note":
|
||||
return await this.noteTools.updateNote(args.path, args.content);
|
||||
case "delete_note":
|
||||
|
||||
@@ -53,7 +53,7 @@ export class NoteTools {
|
||||
}
|
||||
}
|
||||
|
||||
async createNote(path: string, content: string): Promise<CallToolResult> {
|
||||
async createNote(path: string, content: string, createParents: boolean = false): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
@@ -88,30 +88,72 @@ export class NoteTools {
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit parent folder detection (before write operation)
|
||||
const parentPath = PathUtils.getParentPath(normalizedPath);
|
||||
if (parentPath) {
|
||||
// Check if parent exists
|
||||
if (!PathUtils.pathExists(this.app, parentPath)) {
|
||||
if (createParents) {
|
||||
// Auto-create parent folders recursively
|
||||
try {
|
||||
await this.createParentFolders(parentPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('create parent folders', parentPath, (error as Error).message) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Return clear error before attempting file creation
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.parentFolderNotFound(normalizedPath, parentPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if parent is actually a folder (not a file)
|
||||
if (PathUtils.fileExists(this.app, parentPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFolder(parentPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with file creation
|
||||
try {
|
||||
const file = await this.app.vault.create(normalizedPath, content);
|
||||
return {
|
||||
content: [{ type: "text", text: `Note created successfully: ${file.path}` }]
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = (error as Error).message;
|
||||
|
||||
// Check for parent folder not found error
|
||||
if (errorMsg.includes('parent folder')) {
|
||||
const parentPath = PathUtils.getParentPath(normalizedPath);
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.parentFolderNotFound(normalizedPath, parentPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('create note', normalizedPath, errorMsg) }],
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('create note', normalizedPath, (error as Error).message) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create parent folders
|
||||
* @private
|
||||
*/
|
||||
private async createParentFolders(path: string): Promise<void> {
|
||||
// Get parent path
|
||||
const parentPath = PathUtils.getParentPath(path);
|
||||
|
||||
// If there's a parent and it doesn't exist, create it first (recursion)
|
||||
if (parentPath && !PathUtils.pathExists(this.app, parentPath)) {
|
||||
await this.createParentFolders(parentPath);
|
||||
}
|
||||
|
||||
// Create the current folder if it doesn't exist
|
||||
if (!PathUtils.pathExists(this.app, path)) {
|
||||
await this.app.vault.createFolder(path);
|
||||
}
|
||||
}
|
||||
|
||||
async updateNote(path: string, content: string): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
|
||||
40
src/utils/auth-utils.ts
Normal file
40
src/utils/auth-utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Utility functions for authentication and API key management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random API key
|
||||
* @param length Length of the API key (default: 32 characters)
|
||||
* @returns A random API key string
|
||||
*/
|
||||
export function generateApiKey(length: number = 32): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
const values = new Uint8Array(length);
|
||||
|
||||
// Use crypto.getRandomValues for cryptographically secure random numbers
|
||||
crypto.getRandomValues(values);
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset[values[i] % charset.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates API key strength
|
||||
* @param apiKey The API key to validate
|
||||
* @returns Object with isValid flag and optional error message
|
||||
*/
|
||||
export function validateApiKey(apiKey: string): { isValid: boolean; error?: string } {
|
||||
if (!apiKey || apiKey.trim() === '') {
|
||||
return { isValid: false, error: 'API key cannot be empty' };
|
||||
}
|
||||
|
||||
if (apiKey.length < 16) {
|
||||
return { isValid: false, error: 'API key must be at least 16 characters long' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
@@ -86,16 +86,22 @@ Troubleshooting tips:
|
||||
* Generate a parent folder not found error message
|
||||
*/
|
||||
static parentFolderNotFound(path: string, parentPath: string): string {
|
||||
const grandparentPath = PathUtils.getParentPath(parentPath);
|
||||
const listCommand = grandparentPath ? `list_notes("${grandparentPath}")` : 'list_notes()';
|
||||
|
||||
return `Parent folder does not exist: "${parentPath}"
|
||||
|
||||
Cannot create "${path}" because its parent folder is missing.
|
||||
|
||||
Troubleshooting tips:
|
||||
• Use createParents: true parameter to automatically create missing parent folders
|
||||
• Create the parent folder first using Obsidian
|
||||
• Verify the folder path with list_notes("${PathUtils.getParentPath(parentPath) || '/'}")
|
||||
• Verify the folder path with ${listCommand}
|
||||
• Check that the parent folder path is correct (vault-relative, case-sensitive on macOS/Linux)
|
||||
• Note: Automatic parent folder creation is not currently enabled
|
||||
• Ensure all parent folders in the path exist before creating the file`;
|
||||
• Ensure all parent folders in the path exist before creating the file
|
||||
|
||||
Example with auto-creation:
|
||||
create_note({ path: "${path}", content: "...", createParents: true })`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user