feat: Phase 10 - UI Notifications (request-only)

Implement visual feedback for MCP tool calls with configurable notifications.

Features:
- Real-time notifications when tools are called (request only, no completion)
- Tool-specific emoji icons for visual clarity
- Rate limiting (max 10 notifications/second)
- Notification history tracking (last 100 entries)
- Configurable settings: enable/disable, show parameters, duration, console logging
- History modal with filtering and export to clipboard

Implementation:
- Created NotificationManager with queue-based rate limiting
- Created NotificationHistoryModal for viewing past tool calls
- Integrated into tool call interceptor in ToolRegistry
- Added notification settings UI section
- Added 'View MCP Notification History' command

Benefits:
- Visual feedback for debugging and monitoring
- Transparency into AI agent actions
- Simple on/off toggle, no complex verbosity settings
- Zero performance impact when disabled
- History tracks success/failure/duration for all calls

All 10 phases of the roadmap are now complete\!
This commit is contained in:
2025-10-17 01:11:10 -04:00
parent 6017f879f4
commit b681327970
10 changed files with 1178 additions and 65 deletions

View File

@@ -2,15 +2,21 @@ import { Notice, Plugin } from 'obsidian';
import { MCPServer } from './server/mcp-server';
import { MCPPluginSettings, DEFAULT_SETTINGS } from './types/settings-types';
import { MCPServerSettingTab } from './settings';
import { NotificationManager } from './ui/notifications';
import { NotificationHistoryModal } from './ui/notification-history';
export default class MCPServerPlugin extends Plugin {
settings!: MCPPluginSettings;
mcpServer: MCPServer | null = null;
statusBarItem: HTMLElement | null = null;
notificationManager: NotificationManager | null = null;
async onload() {
await this.loadSettings();
// Initialize notification manager
this.updateNotificationManager();
// Add status bar item
this.statusBarItem = this.addStatusBarItem();
this.updateStatusBar();
@@ -50,6 +56,14 @@ export default class MCPServerPlugin extends Plugin {
}
});
this.addCommand({
id: 'view-notification-history',
name: 'View MCP Notification History',
callback: () => {
this.showNotificationHistory();
}
});
// Add settings tab
this.addSettingTab(new MCPServerSettingTab(this.app, this));
@@ -126,4 +140,43 @@ export default class MCPServerPlugin extends Plugin {
this.mcpServer.updateSettings(this.settings);
}
}
/**
* Update or create notification manager based on settings
*/
updateNotificationManager() {
if (this.settings.notificationsEnabled) {
if (!this.notificationManager) {
this.notificationManager = new NotificationManager(this.app, this.settings);
} else {
this.notificationManager.updateSettings(this.settings);
}
// Update server's tool registry if server is running
if (this.mcpServer) {
this.mcpServer.setNotificationManager(this.notificationManager);
}
} else {
this.notificationManager = null;
// Clear notification manager from server if running
if (this.mcpServer) {
this.mcpServer.setNotificationManager(null);
}
}
}
/**
* Show notification history modal
*/
showNotificationHistory() {
if (!this.notificationManager) {
new Notice('Notifications are not enabled. Enable them in settings to view history.');
return;
}
const history = this.notificationManager.getHistory();
const modal = new NotificationHistoryModal(this.app, history);
modal.open();
}
}

View File

@@ -11,6 +11,7 @@ import {
} from '../types/mcp-types';
import { MCPServerSettings } from '../types/settings-types';
import { ToolRegistry } from '../tools';
import { NotificationManager } from '../ui/notifications';
import { setupMiddleware } from './middleware';
import { setupRoutes } from './routes';
@@ -141,4 +142,11 @@ export class MCPServer {
public updateSettings(settings: MCPServerSettings): void {
this.settings = settings;
}
/**
* Set notification manager for tool call notifications
*/
public setNotificationManager(manager: NotificationManager | null): void {
this.toolRegistry.setNotificationManager(manager);
}
}

View File

@@ -265,5 +265,81 @@ export class MCPServerSettingTab extends PluginSettingTab {
healthEndpoint.style.userSelect = 'all';
healthEndpoint.style.cursor = 'text';
}
// Notification Settings
containerEl.createEl('h3', {text: 'UI Notifications'});
const notifDesc = containerEl.createEl('p', {
text: 'Display notifications in Obsidian UI when MCP tools are called. Useful for monitoring API activity and debugging.'
});
notifDesc.style.fontSize = '0.9em';
notifDesc.style.color = 'var(--text-muted)';
notifDesc.style.marginBottom = '12px';
// Enable notifications
new Setting(containerEl)
.setName('Enable notifications')
.setDesc('Show notifications when MCP tools are called (request only, no completion notifications)')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.notificationsEnabled)
.onChange(async (value) => {
this.plugin.settings.notificationsEnabled = value;
await this.plugin.saveSettings();
this.plugin.updateNotificationManager();
this.display();
}));
// Show notification settings only if enabled
if (this.plugin.settings.notificationsEnabled) {
// Show parameters
new Setting(containerEl)
.setName('Show parameters')
.setDesc('Include tool parameters in notifications')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showParameters)
.onChange(async (value) => {
this.plugin.settings.showParameters = value;
await this.plugin.saveSettings();
this.plugin.updateNotificationManager();
}));
// Notification duration
new Setting(containerEl)
.setName('Notification duration')
.setDesc('How long notifications stay visible (milliseconds)')
.addText(text => text
.setPlaceholder('3000')
.setValue(String(this.plugin.settings.notificationDuration))
.onChange(async (value) => {
const duration = parseInt(value);
if (!isNaN(duration) && duration > 0) {
this.plugin.settings.notificationDuration = duration;
await this.plugin.saveSettings();
this.plugin.updateNotificationManager();
}
}));
// Log to console
new Setting(containerEl)
.setName('Log to console')
.setDesc('Also log tool calls to browser console')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.logToConsole)
.onChange(async (value) => {
this.plugin.settings.logToConsole = value;
await this.plugin.saveSettings();
this.plugin.updateNotificationManager();
}));
// View history button
new Setting(containerEl)
.setName('Notification history')
.setDesc('View recent MCP tool calls')
.addButton(button => button
.setButtonText('View History')
.onClick(() => {
this.plugin.showNotificationHistory();
}));
}
}
}

View File

@@ -2,16 +2,25 @@ import { App } from 'obsidian';
import { Tool, CallToolResult } from '../types/mcp-types';
import { NoteTools } from './note-tools';
import { VaultTools } from './vault-tools';
import { NotificationManager } from '../ui/notifications';
export class ToolRegistry {
private noteTools: NoteTools;
private vaultTools: VaultTools;
private notificationManager: NotificationManager | null = null;
constructor(app: App) {
this.noteTools = new NoteTools(app);
this.vaultTools = new VaultTools(app);
}
/**
* Set notification manager for tool call notifications
*/
setNotificationManager(manager: NotificationManager | null): void {
this.notificationManager = manager;
}
getToolDefinitions(): Tool[] {
return [
{
@@ -444,52 +453,68 @@ export class ToolRegistry {
}
async callTool(name: string, args: any): Promise<CallToolResult> {
const startTime = Date.now();
// Show tool call notification
if (this.notificationManager) {
this.notificationManager.showToolCall(name, args);
}
try {
let result: CallToolResult;
switch (name) {
case "read_note":
return await this.noteTools.readNote(args.path, {
result = await this.noteTools.readNote(args.path, {
withFrontmatter: args.withFrontmatter,
withContent: args.withContent,
parseFrontmatter: args.parseFrontmatter
});
break;
case "create_note":
return await this.noteTools.createNote(
result = await this.noteTools.createNote(
args.path,
args.content,
args.createParents ?? false,
args.onConflict ?? 'error'
);
break;
case "update_note":
return await this.noteTools.updateNote(args.path, args.content);
result = await this.noteTools.updateNote(args.path, args.content);
break;
case "update_frontmatter":
return await this.noteTools.updateFrontmatter(
result = await this.noteTools.updateFrontmatter(
args.path,
args.patch,
args.remove ?? [],
args.ifMatch
);
break;
case "update_sections":
return await this.noteTools.updateSections(
result = await this.noteTools.updateSections(
args.path,
args.edits,
args.ifMatch
);
break;
case "rename_file":
return await this.noteTools.renameFile(
result = await this.noteTools.renameFile(
args.path,
args.newPath,
args.updateLinks ?? true,
args.ifMatch
);
break;
case "delete_note":
return await this.noteTools.deleteNote(
result = await this.noteTools.deleteNote(
args.path,
args.soft ?? true,
args.dryRun ?? false,
args.ifMatch
);
break;
case "search":
return await this.vaultTools.search({
result = await this.vaultTools.search({
query: args.query,
isRegex: args.isRegex,
caseSensitive: args.caseSensitive,
@@ -500,12 +525,15 @@ export class ToolRegistry {
snippetLength: args.snippetLength,
maxResults: args.maxResults
});
break;
case "search_waypoints":
return await this.vaultTools.searchWaypoints(args.folder);
result = await this.vaultTools.searchWaypoints(args.folder);
break;
case "get_vault_info":
return await this.vaultTools.getVaultInfo();
result = await this.vaultTools.getVaultInfo();
break;
case "list":
return await this.vaultTools.list({
result = await this.vaultTools.list({
path: args.path,
recursive: args.recursive,
includes: args.includes,
@@ -515,38 +543,76 @@ export class ToolRegistry {
cursor: args.cursor,
withFrontmatterSummary: args.withFrontmatterSummary
});
break;
case "stat":
return await this.vaultTools.stat(args.path);
result = await this.vaultTools.stat(args.path);
break;
case "exists":
return await this.vaultTools.exists(args.path);
result = await this.vaultTools.exists(args.path);
break;
case "read_excalidraw":
return await this.noteTools.readExcalidraw(args.path, {
result = await this.noteTools.readExcalidraw(args.path, {
includeCompressed: args.includeCompressed,
includePreview: args.includePreview
});
break;
case "get_folder_waypoint":
return await this.vaultTools.getFolderWaypoint(args.path);
result = await this.vaultTools.getFolderWaypoint(args.path);
break;
case "is_folder_note":
return await this.vaultTools.isFolderNote(args.path);
result = await this.vaultTools.isFolderNote(args.path);
break;
case "validate_wikilinks":
return await this.vaultTools.validateWikilinks(args.path);
result = await this.vaultTools.validateWikilinks(args.path);
break;
case "resolve_wikilink":
return await this.vaultTools.resolveWikilink(args.sourcePath, args.linkText);
result = await this.vaultTools.resolveWikilink(args.sourcePath, args.linkText);
break;
case "backlinks":
return await this.vaultTools.getBacklinks(
result = await this.vaultTools.getBacklinks(
args.path,
args.includeUnlinked ?? false,
args.includeSnippets ?? true
);
break;
default:
return {
result = {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true
};
}
// Add to history (no completion notification)
const duration = Date.now() - startTime;
if (this.notificationManager) {
this.notificationManager.addToHistory({
timestamp: Date.now(),
toolName: name,
args: args,
success: !result.isError,
duration: duration
});
}
return result;
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = (error as Error).message;
// Add to history (no error notification shown)
if (this.notificationManager) {
this.notificationManager.addToHistory({
timestamp: Date.now(),
toolName: name,
args: args,
success: false,
duration: duration,
error: errorMessage
});
}
return {
content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true
};
}

View File

@@ -7,7 +7,14 @@ export interface MCPServerSettings {
enableAuth: boolean;
}
export interface MCPPluginSettings extends MCPServerSettings {
export interface NotificationSettings {
notificationsEnabled: boolean;
showParameters: boolean;
notificationDuration: number; // milliseconds
logToConsole: boolean;
}
export interface MCPPluginSettings extends MCPServerSettings, NotificationSettings {
autoStart: boolean;
}
@@ -17,5 +24,10 @@ export const DEFAULT_SETTINGS: MCPPluginSettings = {
allowedOrigins: ['*'],
apiKey: '',
enableAuth: false,
autoStart: false
autoStart: false,
// Notification defaults
notificationsEnabled: false,
showParameters: false,
notificationDuration: 3000,
logToConsole: false
};

View File

@@ -0,0 +1,215 @@
import { App, Modal } from 'obsidian';
import { NotificationHistoryEntry } from './notifications';
/**
* Modal for viewing notification history
*/
export class NotificationHistoryModal extends Modal {
private history: NotificationHistoryEntry[];
private filteredHistory: NotificationHistoryEntry[];
private filterTool: string = '';
private filterType: 'all' | 'success' | 'error' = 'all';
constructor(app: App, history: NotificationHistoryEntry[]) {
super(app);
this.history = history;
this.filteredHistory = [...history];
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('mcp-notification-history-modal');
// Title
contentEl.createEl('h2', { text: 'MCP Notification History' });
// Filters
this.createFilters(contentEl);
// History list
this.createHistoryList(contentEl);
// Actions
this.createActions(contentEl);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
/**
* Create filter controls
*/
private createFilters(containerEl: HTMLElement): void {
const filterContainer = containerEl.createDiv({ cls: 'mcp-history-filters' });
filterContainer.style.marginBottom = '16px';
filterContainer.style.display = 'flex';
filterContainer.style.gap = '12px';
filterContainer.style.flexWrap = 'wrap';
// Tool name filter
const toolFilterContainer = filterContainer.createDiv();
toolFilterContainer.createEl('label', { text: 'Tool: ' });
const toolInput = toolFilterContainer.createEl('input', {
type: 'text',
placeholder: 'Filter by tool name...'
});
toolInput.style.marginLeft = '4px';
toolInput.style.padding = '4px 8px';
toolInput.addEventListener('input', (e) => {
this.filterTool = (e.target as HTMLInputElement).value.toLowerCase();
this.applyFilters();
});
// Type filter
const typeFilterContainer = filterContainer.createDiv();
typeFilterContainer.createEl('label', { text: 'Type: ' });
const typeSelect = typeFilterContainer.createEl('select');
typeSelect.style.marginLeft = '4px';
typeSelect.style.padding = '4px 8px';
const allOption = typeSelect.createEl('option', { text: 'All', value: 'all' });
const successOption = typeSelect.createEl('option', { text: 'Success', value: 'success' });
const errorOption = typeSelect.createEl('option', { text: 'Error', value: 'error' });
typeSelect.addEventListener('change', (e) => {
this.filterType = (e.target as HTMLSelectElement).value as 'all' | 'success' | 'error';
this.applyFilters();
});
// Results count
const countEl = filterContainer.createDiv({ cls: 'mcp-history-count' });
countEl.style.marginLeft = 'auto';
countEl.style.alignSelf = 'center';
countEl.textContent = `${this.filteredHistory.length} entries`;
}
/**
* Create history list
*/
private createHistoryList(containerEl: HTMLElement): void {
const listContainer = containerEl.createDiv({ cls: 'mcp-history-list' });
listContainer.style.maxHeight = '400px';
listContainer.style.overflowY = 'auto';
listContainer.style.marginBottom = '16px';
listContainer.style.border = '1px solid var(--background-modifier-border)';
listContainer.style.borderRadius = '4px';
if (this.filteredHistory.length === 0) {
const emptyEl = listContainer.createDiv({ cls: 'mcp-history-empty' });
emptyEl.style.padding = '24px';
emptyEl.style.textAlign = 'center';
emptyEl.style.color = 'var(--text-muted)';
emptyEl.textContent = 'No entries found';
return;
}
this.filteredHistory.forEach((entry, index) => {
const entryEl = listContainer.createDiv({ cls: 'mcp-history-entry' });
entryEl.style.padding = '12px';
entryEl.style.borderBottom = index < this.filteredHistory.length - 1
? '1px solid var(--background-modifier-border)'
: 'none';
// Header row
const headerEl = entryEl.createDiv({ cls: 'mcp-history-entry-header' });
headerEl.style.display = 'flex';
headerEl.style.justifyContent = 'space-between';
headerEl.style.marginBottom = '8px';
// Tool name and status
const titleEl = headerEl.createDiv();
const statusIcon = entry.success ? '✅' : '❌';
const toolName = titleEl.createEl('strong', { text: `${statusIcon} ${entry.toolName}` });
toolName.style.color = entry.success ? 'var(--text-success)' : 'var(--text-error)';
// Timestamp and duration
const metaEl = headerEl.createDiv();
metaEl.style.fontSize = '0.85em';
metaEl.style.color = 'var(--text-muted)';
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
const durationStr = entry.duration ? `${entry.duration}ms` : '';
metaEl.textContent = `${timestamp}${durationStr}`;
// Arguments
if (entry.args && Object.keys(entry.args).length > 0) {
const argsEl = entryEl.createDiv({ cls: 'mcp-history-entry-args' });
argsEl.style.fontSize = '0.85em';
argsEl.style.fontFamily = 'monospace';
argsEl.style.backgroundColor = 'var(--background-secondary)';
argsEl.style.padding = '8px';
argsEl.style.borderRadius = '4px';
argsEl.style.marginBottom = '8px';
argsEl.style.overflowX = 'auto';
argsEl.textContent = JSON.stringify(entry.args, null, 2);
}
// Error message
if (!entry.success && entry.error) {
const errorEl = entryEl.createDiv({ cls: 'mcp-history-entry-error' });
errorEl.style.fontSize = '0.85em';
errorEl.style.color = 'var(--text-error)';
errorEl.style.backgroundColor = 'var(--background-secondary)';
errorEl.style.padding = '8px';
errorEl.style.borderRadius = '4px';
errorEl.style.fontFamily = 'monospace';
errorEl.textContent = entry.error;
}
});
}
/**
* Create action buttons
*/
private createActions(containerEl: HTMLElement): void {
const actionsContainer = containerEl.createDiv({ cls: 'mcp-history-actions' });
actionsContainer.style.display = 'flex';
actionsContainer.style.gap = '8px';
actionsContainer.style.justifyContent = 'flex-end';
// Export button
const exportButton = actionsContainer.createEl('button', { text: 'Export to Clipboard' });
exportButton.addEventListener('click', async () => {
const exportData = JSON.stringify(this.filteredHistory, null, 2);
await navigator.clipboard.writeText(exportData);
// Show temporary success message
exportButton.textContent = '✅ Copied!';
setTimeout(() => {
exportButton.textContent = 'Export to Clipboard';
}, 2000);
});
// Close button
const closeButton = actionsContainer.createEl('button', { text: 'Close' });
closeButton.addEventListener('click', () => {
this.close();
});
}
/**
* Apply filters to history
*/
private applyFilters(): void {
this.filteredHistory = this.history.filter(entry => {
// Tool name filter
if (this.filterTool && !entry.toolName.toLowerCase().includes(this.filterTool)) {
return false;
}
// Type filter
if (this.filterType === 'success' && !entry.success) {
return false;
}
if (this.filterType === 'error' && entry.success) {
return false;
}
return true;
});
// Re-render
this.onOpen();
}
}

232
src/ui/notifications.ts Normal file
View File

@@ -0,0 +1,232 @@
import { App, Notice } from 'obsidian';
import { MCPPluginSettings } from '../types/settings-types';
/**
* Notification history entry
*/
export interface NotificationHistoryEntry {
timestamp: number;
toolName: string;
args: any;
success: boolean;
duration?: number;
error?: string;
}
/**
* Tool icon mapping
*/
const TOOL_ICONS: Record<string, string> = {
read_note: '📖',
read_excalidraw: '📖',
create_note: '✏️',
update_note: '✏️',
update_frontmatter: '✏️',
update_sections: '✏️',
delete_note: '🗑️',
rename_file: '📝',
search: '🔍',
search_waypoints: '🔍',
list: '📋',
list_notes: '📋',
stat: '📊',
exists: '📊',
get_vault_info: '',
get_folder_waypoint: '🗺️',
is_folder_note: '📁',
validate_wikilinks: '🔗',
resolve_wikilink: '🔗',
backlinks: '🔗'
};
/**
* Notification manager for MCP tool calls
* Displays notifications in the Obsidian UI with rate limiting
*/
export class NotificationManager {
private app: App;
private settings: MCPPluginSettings;
private history: NotificationHistoryEntry[] = [];
private maxHistorySize = 100;
// Rate limiting
private notificationQueue: Array<() => void> = [];
private isProcessingQueue = false;
private maxNotificationsPerSecond = 10;
private notificationInterval = 1000 / this.maxNotificationsPerSecond; // 100ms between notifications
// Batching
private pendingToolCalls: Map<string, number> = new Map();
private batchTimeout: NodeJS.Timeout | null = null;
constructor(app: App, settings: MCPPluginSettings) {
this.app = app;
this.settings = settings;
}
/**
* Update settings reference
*/
updateSettings(settings: MCPPluginSettings): void {
this.settings = settings;
}
/**
* Show notification for tool call start
*/
showToolCall(toolName: string, args: any, duration?: number): void {
if (!this.shouldShowNotification()) {
return;
}
const icon = TOOL_ICONS[toolName] || '🔧';
const argsStr = this.formatArgs(args);
const message = `${icon} MCP: ${toolName}${argsStr}`;
this.queueNotification(() => {
new Notice(message, duration || this.settings.notificationDuration);
});
// Log to console if enabled
if (this.settings.logToConsole) {
console.log(`[MCP] Tool call: ${toolName}`, args);
}
}
/**
* Add entry to notification history
*/
addToHistory(entry: NotificationHistoryEntry): void {
this.history.unshift(entry);
// Limit history size
if (this.history.length > this.maxHistorySize) {
this.history = this.history.slice(0, this.maxHistorySize);
}
}
/**
* Get notification history
*/
getHistory(): NotificationHistoryEntry[] {
return [...this.history];
}
/**
* Clear notification history
*/
clearHistory(): void {
this.history = [];
}
/**
* Clear all active notifications (not possible with Obsidian API)
*/
clearAll(): void {
// Obsidian doesn't provide API to clear notices
// This is a no-op for compatibility
}
/**
* Check if notification should be shown
*/
private shouldShowNotification(): boolean {
return this.settings.notificationsEnabled;
}
/**
* Format arguments for display
*/
private formatArgs(args: any): string {
if (!this.settings.showParameters) {
return '';
}
if (!args || Object.keys(args).length === 0) {
return '()';
}
try {
// Extract key parameters for display
const keyParams: string[] = [];
if (args.path) {
keyParams.push(`path: "${this.truncateString(args.path, 30)}"`);
}
if (args.query) {
keyParams.push(`query: "${this.truncateString(args.query, 30)}"`);
}
if (args.folder) {
keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`);
}
if (args.recursive !== undefined) {
keyParams.push(`recursive: ${args.recursive}`);
}
// If no key params, show first 50 chars of JSON
if (keyParams.length === 0) {
const json = JSON.stringify(args);
return `(${this.truncateString(json, 50)})`;
}
return `({ ${keyParams.join(', ')} })`;
} catch (e) {
return '(...)';
}
}
/**
* Truncate string to max length
*/
private truncateString(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str;
}
return str.substring(0, maxLength - 3) + '...';
}
/**
* Queue notification with rate limiting
*/
private queueNotification(notificationFn: () => void): void {
this.notificationQueue.push(notificationFn);
if (!this.isProcessingQueue) {
this.processQueue();
}
}
/**
* Process notification queue with rate limiting
*/
private async processQueue(): Promise<void> {
if (this.isProcessingQueue) {
return;
}
this.isProcessingQueue = true;
while (this.notificationQueue.length > 0) {
const notificationFn = this.notificationQueue.shift();
if (notificationFn) {
notificationFn();
}
// Wait before processing next notification
if (this.notificationQueue.length > 0) {
await this.sleep(this.notificationInterval);
}
}
this.isProcessingQueue = false;
}
/**
* Sleep helper
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}