From 09a4abba57a39e06b470b7cb8d2437dda2c2762c Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Thu, 13 Aug 2026 14:06:33 -0400 Subject: [PATCH 1/6] [Feature] Add option to keep MCP server running on tool open/reopen --- src/common/types/settings.ts | 1 + src/main/index.ts | 63 +++++++++++++++++++-- src/main/managers/settingsManager.ts | 3 +- src/main/managers/toolWindowManager.ts | 16 ++++++ src/main/mcp/agentToolRegistry.ts | 54 ++++++++++++++++-- src/main/mcp/headlessToolRuntime.ts | 5 +- src/main/mcp/mcpServer.ts | 71 ++++++++++++++++++++++-- src/renderer/modules/mcpManagement.ts | 77 +++++++++++++++++++++++++- 8 files changed, 273 insertions(+), 17 deletions(-) diff --git a/src/common/types/settings.ts b/src/common/types/settings.ts index e4e51900..30c187df 100644 --- a/src/common/types/settings.ts +++ b/src/common/types/settings.ts @@ -131,6 +131,7 @@ export interface UserSettings { machineId?: string; // @deprecated - legacy machine identifier retained for migrations pendingWhatsNewVersion?: string | null; // Version whose What's New should be shown after restart (auto-update) restoreSessionOnStartup?: boolean; // Whether to reopen previously open tools on app start + keepMcpServerRunning?: boolean; // Keep MCP server running by auto-starting it when tools open/reopen // Sort preferences installedToolsSort?: InstalledToolsSortOption; connectionsSort?: ConnectionsSortOption; diff --git a/src/main/index.ts b/src/main/index.ts index 03904685..dbf403ec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -54,8 +54,8 @@ import { TrayManager } from "./managers/trayManager"; import { VersionManager } from "./managers/versionManager"; import { readLogEntries } from "./mcp/agentInvocationLogger"; import { McpServerManager } from "./mcp/mcpServer"; -import { ActiveToolInfo, buildToolBoxFeedbackUrl, buildToolFeedbackUrl, getEnvironmentDiagnostics, resolveActiveToolInfo } from "./utilities"; import { applyMainSentryConsent } from "./sentryRuntime"; +import { ActiveToolInfo, buildToolBoxFeedbackUrl, buildToolFeedbackUrl, getEnvironmentDiagnostics, resolveActiveToolInfo } from "./utilities"; // Constants const MENU_CREATION_DEBOUNCE_MS = 150; // Debounce delay for menu recreation during rapid tool switches @@ -67,6 +67,11 @@ const FAVICON_MAX_BYTES = 65536; // 64 KB — more than enough for any favicon const OPEN_EXTERNAL_ALLOWED_PROTOCOLS = new Set(["https:", "http:", "mailto:"]); const OPEN_IN_CONNECTION_BROWSER_ALLOWED_PROTOCOLS = new Set(["https:", "http:"]); +function isExpectedAutoUpdateUnavailableError(errorMessage: string): boolean { + const normalized = errorMessage.toLowerCase(); + return normalized.includes("only available in packaged releases") || normalized.includes("updater metadata is missing") || normalized.includes("only supported for the windows nsis"); +} + const isFaviconAllowedHost = (hostname: string): boolean => { if (FAVICON_ALLOWED_HOSTS.has(hostname)) return true; return FAVICON_ALLOWED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); @@ -100,6 +105,7 @@ class ToolBoxApp { private menuCreationTimeout: NodeJS.Timeout | null = null; // Debounce timer for menu recreation private isQuitting = false; // True once the user explicitly quits (e.g. tray "Quit" or Cmd+Q) private shouldFocusAfterWindowCreation = false; // Tracks a relaunch request before main window exists + private mcpAutoStartInProgress = false; /** * Resolve the application icon for the current release channel. @@ -123,7 +129,10 @@ class ToolBoxApp { try { this.settingsManager = new SettingsManager(); this.installIdManager = new InstallIdManager(this.settingsManager); - void applyMainSentryConsent(this.settingsManager.getSentryTelemetryConsent(), this.settingsManager.getSentryTelemetryConsent() === "yes" ? this.installIdManager.getInstallId() : undefined); + void applyMainSentryConsent( + this.settingsManager.getSentryTelemetryConsent(), + this.settingsManager.getSentryTelemetryConsent() === "yes" ? this.installIdManager.getInstallId() : undefined, + ); this.connectionsManager = new ConnectionsManager(); this.api = new ToolBoxUtilityManager(); @@ -277,6 +286,11 @@ class ToolBoxApp { }); this.autoUpdateManager.on("update-error", (error) => { + if (error?.message && isExpectedAutoUpdateUnavailableError(error.message)) { + logInfo(`[AutoUpdate] Suppressing expected update-unavailable notification: ${error.message}`); + return; + } + this.api.showNotification({ title: "Update Error", body: `Failed to check for updates: ${error.message}`, @@ -509,7 +523,10 @@ class ToolBoxApp { ipcMain.handle(SETTINGS_CHANNELS.UPDATE_USER_SETTINGS, async (_, settings) => { this.settingsManager.updateUserSettings(settings); if (Object.prototype.hasOwnProperty.call(settings, "sentryTelemetryConsent")) { - await applyMainSentryConsent(this.settingsManager.getSentryTelemetryConsent(), this.settingsManager.getSentryTelemetryConsent() === "yes" ? this.installIdManager.getInstallId() : undefined); + await applyMainSentryConsent( + this.settingsManager.getSentryTelemetryConsent(), + this.settingsManager.getSentryTelemetryConsent() === "yes" ? this.installIdManager.getInstallId() : undefined, + ); } this.api.emitEvent(ToolBoxEvent.SETTINGS_UPDATED, settings); }); @@ -521,7 +538,10 @@ class ToolBoxApp { ipcMain.handle(SETTINGS_CHANNELS.SET_SETTING, async (_, key, value) => { this.settingsManager.setSetting(key, value); if (key === "sentryTelemetryConsent") { - await applyMainSentryConsent(this.settingsManager.getSentryTelemetryConsent(), this.settingsManager.getSentryTelemetryConsent() === "yes" ? this.installIdManager.getInstallId() : undefined); + await applyMainSentryConsent( + this.settingsManager.getSentryTelemetryConsent(), + this.settingsManager.getSentryTelemetryConsent() === "yes" ? this.installIdManager.getInstallId() : undefined, + ); } }); @@ -2940,6 +2960,9 @@ class ToolBoxApp { this.toolWindowManager.setOnActiveToolChanged(() => { this.debouncedCreateMenu(); }); + this.toolWindowManager.setOnToolLaunched(async () => { + await this.ensureMcpServerRunningForMode("tool-launch"); + }); // Initialize NotificationWindowManager for overlay notifications this.notificationWindowManager = new NotificationWindowManager(this.mainWindow, this.settingsManager); @@ -2961,6 +2984,7 @@ class ToolBoxApp { // After the renderer is ready, auto-open What's New if an auto-update was installed. this.mainWindow.webContents.once("did-finish-load", () => { this.openWhatsNewIfPending(); + void this.ensureMcpServerRunningForMode("startup"); }); // Open DevTools in development @@ -2998,6 +3022,37 @@ class ToolBoxApp { } } + private async ensureMcpServerRunningForMode(mode: "tool-launch" | "startup"): Promise { + const keepMcpServerRunning = this.settingsManager.getSetting("keepMcpServerRunning"); + if (!keepMcpServerRunning || this.mcpServerManager.isRunning() || this.mcpAutoStartInProgress) { + return; + } + + this.mcpAutoStartInProgress = true; + try { + await this.mcpServerManager.start(); + this.trayManager?.refreshContextMenu(); + const body = + mode === "startup" + ? "MCP server was automatically started at startup because Keep MCP Server Running is enabled." + : "MCP server was automatically started because Keep MCP Server Running is enabled."; + this.api.showNotification({ + title: "MCP Server Started", + body, + type: "success", + }); + } catch (error) { + logError(`[MCP] Failed to auto-start server (${mode})`, error); + this.api.showNotification({ + title: "MCP Server Auto-Start Failed", + body: mode === "startup" ? "Unable to automatically start MCP server at startup." : "Unable to automatically start MCP server after tool launch.", + type: "error", + }); + } finally { + this.mcpAutoStartInProgress = false; + } + } + /** * Show About dialog with version and environment info * Includes install ID and other important information for diagnostics diff --git a/src/main/managers/settingsManager.ts b/src/main/managers/settingsManager.ts index 2b8d9e23..0818b929 100644 --- a/src/main/managers/settingsManager.ts +++ b/src/main/managers/settingsManager.ts @@ -1,7 +1,7 @@ import { randomBytes } from "crypto"; import Store from "electron-store"; -import { CspConsentRecord, LastUsedToolConnectionInfo, LastUsedToolEntry, LastUsedToolUpdate, MarketplaceSource, TelemetryConsentChoice, ToolSettings, UserSettings } from "../../common/types"; import { normalizeTelemetryConsent } from "../../common/telemetryConsent"; +import { CspConsentRecord, LastUsedToolConnectionInfo, LastUsedToolEntry, LastUsedToolUpdate, MarketplaceSource, TelemetryConsentChoice, ToolSettings, UserSettings } from "../../common/types"; import { buildPreviewFeatureFlags } from "../../common/types/settings"; import { AZURE_BLOB_BASE_URL } from "../constants"; @@ -41,6 +41,7 @@ export class SettingsManager { toolSecondaryConnections: {}, // Map of toolId to secondary connectionId connectionsSort: "last-used", restoreSessionOnStartup: true, // Reopen previously open tools on app start + keepMcpServerRunning: false, // Auto-start MCP server when tools open/reopen enablePreviewFeatures: false, // Show preview/experimental features in the UI previewFeatures: buildPreviewFeatureFlags(), // Per-feature preview toggles marketplaceSources: this.getDefaultMarketplaceSources(), diff --git a/src/main/managers/toolWindowManager.ts b/src/main/managers/toolWindowManager.ts index 49ffdad4..291cade8 100644 --- a/src/main/managers/toolWindowManager.ts +++ b/src/main/managers/toolWindowManager.ts @@ -113,6 +113,7 @@ export class ToolWindowManager { private showListener: () => void; private rendererInitializedListener: () => void; private onActiveToolChanged: ((activeToolId: string | null) => void) | null = null; + private onToolLaunched: ((instanceId: string, tool: Tool) => void | Promise) | null = null; constructor( mainWindow: BrowserWindow, @@ -503,6 +504,12 @@ export class ToolWindowManager { secondaryConnection: secondaryConnectionDetails, }); + if (this.onToolLaunched) { + void Promise.resolve(this.onToolLaunched(instanceId, tool)).catch((error) => { + logError("[ToolWindowManager] Tool launch callback failed", error); + }); + } + logInfo(`[ToolWindowManager] Tool instance launched successfully: ${instanceId}`); return true; } catch (error) { @@ -1295,6 +1302,15 @@ export class ToolWindowManager { this.onActiveToolChanged = callback ?? null; } + setOnToolLaunched(callback: ((instanceId: string, tool: Tool) => void | Promise) | null | undefined): void { + if (callback !== null && callback !== undefined && typeof callback !== "function") { + logWarn("[ToolWindowManager] setOnToolLaunched called with non-function callback"); + return; + } + + this.onToolLaunched = callback ?? null; + } + /** * Invoke the active tool changed callback */ diff --git a/src/main/mcp/agentToolRegistry.ts b/src/main/mcp/agentToolRegistry.ts index 77a419b8..965eb10c 100644 --- a/src/main/mcp/agentToolRegistry.ts +++ b/src/main/mcp/agentToolRegistry.ts @@ -1,8 +1,9 @@ import * as fs from "fs"; import * as path from "path"; import { logInfo } from "../../common/logger"; -import { ToolManifest } from "../../common/types"; +import { Tool, ToolManifest } from "../../common/types"; import { ToolRegistryManager } from "../managers/toolRegistryManager"; +import { ToolManager } from "../managers/toolsManager"; import { convertPPTBSchemaToJsonSchema, JsonObjectSchema } from "./schemaConverter"; export type AgentInvocationMode = "one-way" | "two-way"; @@ -23,6 +24,14 @@ export interface AgentTool { export interface GetAgentInvokableToolsOptions { requireVerified?: boolean; + toolManager?: ToolManager; +} + +interface AgentToolCandidate { + id: string; + name: string; + description: string; + pptbConfigPath: string; } const toolNameMap = new Map(); // friendlyName → internalId @@ -65,20 +74,55 @@ export async function getAgentInvokableTools(toolRegistryManager: ToolRegistryMa const installedTools: ToolManifest[] = await toolRegistryManager.getInstalledTools(); logInfo(`[MCP] Loaded ${installedTools.length} installed tools`); + const candidates: AgentToolCandidate[] = installedTools.map((tool) => ({ + id: tool.id, + name: tool.name, + description: tool.description, + pptbConfigPath: path.join(tool.installPath, "pptb.config.json"), + })); + + const seenToolIds = new Set(candidates.map((candidate) => candidate.id)); + if (options?.toolManager) { + const loadedTools: Tool[] = options.toolManager.getAllTools(); + const localLoadedTools = loadedTools.filter((tool) => typeof tool.localPath === "string" && tool.localPath.length > 0); + + for (const tool of localLoadedTools) { + if (seenToolIds.has(tool.id)) { + continue; + } + + const localPath = tool.localPath; + if (!localPath) { + continue; + } + + candidates.push({ + id: tool.id, + name: tool.name, + description: tool.description, + pptbConfigPath: path.join(localPath, "pptb.config.json"), + }); + seenToolIds.add(tool.id); + } + + if (localLoadedTools.length > 0) { + logInfo(`[MCP] Added ${localLoadedTools.length} locally loaded tools for MCP discovery`); + } + } + const result: AgentTool[] = []; - for (const tool of installedTools) { - const pptbConfigPath = path.join(tool.installPath, "pptb.config.json"); + for (const tool of candidates) { const friendlyName = tool.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); // MCP tool names can't have spaces toolNameMap.set(friendlyName, tool.id); - if (!fs.existsSync(pptbConfigPath)) { + if (!fs.existsSync(tool.pptbConfigPath)) { continue; } let pptbConfig: Record; try { - const raw = fs.readFileSync(pptbConfigPath, "utf-8"); + const raw = fs.readFileSync(tool.pptbConfigPath, "utf-8"); pptbConfig = JSON.parse(raw) as Record; } catch { continue; diff --git a/src/main/mcp/headlessToolRuntime.ts b/src/main/mcp/headlessToolRuntime.ts index c3a928c4..ff4a71f8 100644 --- a/src/main/mcp/headlessToolRuntime.ts +++ b/src/main/mcp/headlessToolRuntime.ts @@ -204,7 +204,10 @@ function resolveCandidatePaths(manifest: ToolManifest): string[] { (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, ); - const resolved = candidates.map((candidate) => path.resolve(installPath, candidate)).filter((candidatePath) => fs.existsSync(candidatePath)); + const supportedExtensions = new Set([".js", ".mjs", ".cjs"]); + const resolved = candidates + .map((candidate) => path.resolve(installPath, candidate)) + .filter((candidatePath) => fs.existsSync(candidatePath) && supportedExtensions.has(path.extname(candidatePath).toLowerCase())); return [...new Set(resolved)]; } diff --git a/src/main/mcp/mcpServer.ts b/src/main/mcp/mcpServer.ts index 9d304f32..f961071a 100644 --- a/src/main/mcp/mcpServer.ts +++ b/src/main/mcp/mcpServer.ts @@ -6,7 +6,7 @@ import { createServer, IncomingMessage, ServerResponse } from "http"; import os from "os"; import path from "path"; import { logError, logInfo } from "../../common/logger"; -import { Connection } from "../../common/types"; +import { Connection, ToolManifest } from "../../common/types"; import { AuthManager } from "../managers/authManager"; import { ConnectionsManager } from "../managers/connectionsManager"; import { DataverseManager } from "../managers/dataverseManager"; @@ -290,6 +290,21 @@ export class McpServerManager { this.agentToolsCache = null; logInfo("[MCP] Agent tool list invalidated: tool uninstalled"); }); + + this.toolManager.on("tool:loaded", () => { + this.agentToolsCache = null; + logInfo("[MCP] Agent tool list invalidated: tool loaded"); + }); + + this.toolManager.on("tool:unloaded", () => { + this.agentToolsCache = null; + logInfo("[MCP] Agent tool list invalidated: tool unloaded"); + }); + + this.toolManager.on("tool:update-completed", () => { + this.agentToolsCache = null; + logInfo("[MCP] Agent tool list invalidated: tool updated"); + }); } setToolWindowManager(twm: ToolWindowManager): void { @@ -419,11 +434,57 @@ export class McpServerManager { private async getAgentTools(): Promise { if (this.agentToolsCache === null) { - this.agentToolsCache = { tools: await getAgentInvokableTools(this.toolRegistryManager) }; + this.agentToolsCache = { + tools: await getAgentInvokableTools(this.toolRegistryManager, { + toolManager: this.toolManager, + }), + }; } return this.agentToolsCache.tools; } + private resolveExecutionManifest(toolId: string): ToolManifest | null { + const installedManifest = this.toolRegistryManager.getInstalledManifestSync(toolId); + if (installedManifest) { + return installedManifest; + } + + const loadedTool = this.toolManager.getTool(toolId); + if (!loadedTool?.localPath) { + return null; + } + + return { + id: loadedTool.id, + name: loadedTool.name, + version: loadedTool.version, + description: loadedTool.description, + installPath: loadedTool.localPath, + installedAt: new Date().toISOString(), + source: "local", + authors: loadedTool.authors, + icon: loadedTool.icon, + cspExceptions: loadedTool.cspExceptions, + categories: loadedTool.categories, + license: loadedTool.license, + downloads: loadedTool.downloads, + rating: loadedTool.rating, + mau: loadedTool.mau, + readme: loadedTool.readmeUrl, + features: loadedTool.features, + status: loadedTool.status, + repository: loadedTool.repository, + website: loadedTool.website, + minAPI: loadedTool.minAPI, + maxAPI: loadedTool.maxAPI, + mcpHeadlessEnabled: loadedTool.mcpHeadlessEnabled, + capabilities: loadedTool.capabilities, + marketplaceSourceId: loadedTool.marketplaceSourceId, + marketplaceSourceLabel: loadedTool.marketplaceSourceLabel, + marketplaceSourceType: loadedTool.marketplaceSourceType, + }; + } + private inferMode(tool: AgentTool, payload: Record, requestedMode: AgentInvocationMode | undefined): AgentInvocationMode { if (requestedMode && tool.invocationModes.includes(requestedMode)) { return requestedMode; @@ -815,10 +876,10 @@ export class McpServerManager { switch (executionMode) { case "headless": { const effectiveTimeoutMs = invocationMeta.timeoutMs ?? matchedTool.timeoutMs ?? DEFAULT_TWO_WAY_TIMEOUT_MS; - const installedManifest = this.toolRegistryManager.getInstalledManifestSync(toolId); + const executionManifest = this.resolveExecutionManifest(toolId); let resolvedAuthContext: ResolvedHeadlessAuthContext; - if (!installedManifest) { + if (!executionManifest) { const errorText = `Tool manifest not found for: ${toolId}`; logInvocationWithMeta({ toolId, @@ -855,7 +916,7 @@ export class McpServerManager { timeoutMs: effectiveTimeoutMs, execute: async (jobId) => { const result = await invokeHeadlessTool( - installedManifest, + executionManifest, prefillData, { toolId, diff --git a/src/renderer/modules/mcpManagement.ts b/src/renderer/modules/mcpManagement.ts index 5538ba17..a3388551 100644 --- a/src/renderer/modules/mcpManagement.ts +++ b/src/renderer/modules/mcpManagement.ts @@ -34,6 +34,20 @@ export function renderMCPServerContent(panel: HTMLElement): void { +
+
+ Startup Behavior +

Automatically keep MCP available by restarting it when tools are opened or reopened.

+
+
+ + +
+
+
Server Address @@ -166,7 +180,7 @@ function updateMcpServerStatusUi(isRunning: boolean): void { */ async function loadAndRenderLogs(): Promise { try { - const [serverDetails, logs] = await Promise.all([window.toolboxAPI.mcpServer.getDetails(), window.toolboxAPI.agentInvocation.getLogs()]); + const [serverDetails, logs, userSettings] = await Promise.all([window.toolboxAPI.mcpServer.getDetails(), window.toolboxAPI.agentInvocation.getLogs(), window.toolboxAPI.getUserSettings()]); const container = document.getElementById("mcp-container"); const emptyState = document.getElementById("mcp-empty"); const table = document.getElementById("invocation-logs-table"); @@ -192,6 +206,7 @@ async function loadAndRenderLogs(): Promise { wireCopyButton("copy-mcp-auth-header-name-btn", () => serverDetails.authHeaderName, "MCP auth header name copied"); wireCopyButton("copy-mcp-auth-header-value-btn", () => serverDetails.authHeaderValue, "MCP auth token copied"); wireMcpServerToggleButton(); + wireKeepMcpServerRunningToggle(serverDetails.isRunning, Boolean(userSettings.keepMcpServerRunning)); wireClientConfigButtons(); if (logs.length === 0) { @@ -230,6 +245,66 @@ async function loadAndRenderLogs(): Promise { } } +function wireKeepMcpServerRunningToggle(isServerRunning: boolean, initialKeepRunning: boolean): void { + const checkbox = document.getElementById("mcp-keep-running-checkbox") as HTMLInputElement | null; + const status = document.getElementById("mcp-keep-running-status") as HTMLSpanElement | null; + + if (!checkbox || !status || checkbox.dataset.bound === "true") { + return; + } + + const setStatus = (message: string, isError: boolean): void => { + status.textContent = message; + status.style.color = isError ? "var(--error-color, #d13438)" : "var(--text-secondary, #8a8886)"; + }; + + checkbox.checked = initialKeepRunning; + setStatus(initialKeepRunning ? "Enabled" : "Disabled", false); + + checkbox.dataset.bound = "true"; + checkbox.addEventListener("change", () => { + void (async () => { + const enabled = checkbox.checked; + checkbox.disabled = true; + setStatus("Saving...", false); + + try { + await window.toolboxAPI.updateUserSettings({ keepMcpServerRunning: enabled }); + + if (enabled && !isServerRunning) { + const details = await window.toolboxAPI.mcpServer.start(); + updateMcpServerStatusUi(details.isRunning); + isServerRunning = details.isRunning; + setStatus("Enabled. MCP server started.", false); + await window.toolboxAPI.utils.showNotification({ + title: "MCP Keep Running Enabled", + body: "MCP server started and will auto-start when tools are reopened.", + type: "success", + }); + } else { + setStatus(enabled ? "Enabled" : "Disabled", false); + await window.toolboxAPI.utils.showNotification({ + title: enabled ? "MCP Keep Running Enabled" : "MCP Keep Running Disabled", + body: enabled ? "MCP server will auto-start when tools are reopened." : "MCP server will not auto-start when tools are reopened.", + type: "success", + }); + } + } catch (error) { + checkbox.checked = !enabled; + setStatus("Failed to update setting.", true); + logError("Failed to update MCP keep-running setting", error); + await window.toolboxAPI.utils.showNotification({ + title: "MCP Keep Running Update Failed", + body: "Unable to update Keep MCP Server Running setting.", + type: "error", + }); + } finally { + checkbox.disabled = false; + } + })(); + }); +} + function wireClientConfigButtons(): void { const claudeBtn = document.getElementById("connect-claude-desktop-btn") as HTMLButtonElement | null; const vscodeBtn = document.getElementById("connect-vscode-btn") as HTMLButtonElement | null; From ef0a7afb452bd50e0e4b91b70cb834d811bdfb49 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Fri, 14 Aug 2026 09:03:55 -0400 Subject: [PATCH 2/6] feat(mcp): enhance headless job management and logging - Introduced HeadlessJobLogEntry and HeadlessJobDetails interfaces for better job logging. - Added MCP_HEADLESS_JOB_UPDATED event to notify job status changes. - Implemented clearLogs functionality in both AgentInvocationAPI and McpServerAPI. - Enhanced ToolBoxApp to handle job status updates and clear logs. - Updated HeadlessToolInvocationManager to manage job logs and notify changes. - Improved agentInvocationLogger to read from multiple log files. - Added UI components for displaying job invocation details and clearing logs. - Implemented tests for McpServerManager's headless auth resolution. --- docs/MCP_IMPLEMENTATION.md | 20 ++ src/common/ipc/channels.ts | 3 + src/common/types/api.ts | 33 +++ src/common/types/events.ts | 1 + src/main/index.ts | 30 ++- .../managers/headlessToolInvocationManager.ts | 67 ++++++ src/main/mcp/agentInvocationLogger.ts | 13 +- src/main/mcp/headlessToolRuntime.ts | 2 + src/main/mcp/mcpServer.ts | 27 ++- src/main/preload.ts | 3 + .../modals/mcpInvocationDetails/controller.ts | 64 ++++++ .../modals/mcpInvocationDetails/view.ts | 131 ++++++++++++ src/renderer/modules/mcpManagement.ts | 199 ++++++++++++++++-- src/renderer/styles.scss | 63 ++++++ tests/unit/main/mcp/mcpServer.test.ts | 57 +++++ tsconfig.json | 42 ++-- 16 files changed, 709 insertions(+), 46 deletions(-) create mode 100644 src/renderer/modals/mcpInvocationDetails/controller.ts create mode 100644 src/renderer/modals/mcpInvocationDetails/view.ts create mode 100644 tests/unit/main/mcp/mcpServer.test.ts diff --git a/docs/MCP_IMPLEMENTATION.md b/docs/MCP_IMPLEMENTATION.md index 6d61c78d..56ce2555 100644 --- a/docs/MCP_IMPLEMENTATION.md +++ b/docs/MCP_IMPLEMENTATION.md @@ -182,6 +182,25 @@ export async function invokeHeadless(input: Record, context: { The globals are installed directly on `globalThis`, so tool code written for windowed execution (`window.dataverseAPI.queryData(...)`) continues to work unchanged because `window` is aliased to `globalThis` by the headless runtime. +### Headless Tool Logs + +Headless invocations now capture tool messages into the live job record so the MCP Server page can drill into an invocation and show its runtime output. + +The supported logging surface is the `context.logger` object passed to `invokeHeadless(...)`: + +```typescript +export async function invokeHeadless( + input: Record, + context: { logger: { debug(message: string): void; info(message: string): void; warn(message: string): void; error(message: string): void } }, +) { + context.logger.info("Starting headless work"); + context.logger.warn("Using fallback data source"); + context.logger.error("Something failed"); +} +``` + +These messages are stored with the job, surfaced in the MCP Server detail pane, and kept bounded so the log history does not grow without limit. + ## Tool Runtime Context When a tool is launched by MCP, `toolboxAPI.invocation.getLaunchContext()` returns the prefill data. If present, invocation metadata is attached under `__pptb`. @@ -224,3 +243,4 @@ The runtime currently: - Use MCP Inspector as the manual test harness for this feature area. - Verify that `list-tools` shows the supported modes and that `call-tool` returns the expected one-way or two-way response. - Check that invocation logs redact connection-related identifiers and sensitive payload fields. +- In the PPTB renderer, the MCP Server page now refreshes live and supports drilling into a selected invocation to inspect the captured job logs, progress, result, and error state. diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index 6013f6f8..f5d14536 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -247,11 +247,14 @@ export const PROTOCOL_CHANNELS = { // Agent Invocation Logging channels export const AGENT_INVOCATION_CHANNELS = { GET_LOGS: "agent-invocation:get-logs", + CLEAR_LOGS: "agent-invocation:clear-logs", } as const; // MCP server status/details channels export const MCP_SERVER_CHANNELS = { GET_DETAILS: "mcp-server:get-details", + GET_JOB_STATUS: "mcp-server:get-job-status", + CLEAR_LOGS: "mcp-server:clear-logs", START: "mcp-server:start", STOP: "mcp-server:stop", CONFIGURE_CLAUDE_DESKTOP: "mcp-server:configure-claude-desktop", diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 0ca69ff8..32695787 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -95,11 +95,42 @@ export interface AgentInvocationLogEntry { error?: string; } +/** + * Log entry captured for a headless MCP job. + */ +export interface HeadlessJobLogEntry { + timestamp: string; + level: "debug" | "info" | "warn" | "error"; + message: string; +} + +/** + * Headless MCP job details shown in the renderer drill-down view. + */ +export interface HeadlessJobDetails { + jobId: string; + toolId: string; + toolName: string; + status: "pending" | "in_progress" | "completed" | "failed"; + createdAt: string; + startedAt?: string; + completedAt?: string; + timeoutMs: number; + progress?: { + percent: number; + message?: string; + }; + result?: Record; + error?: string; + logs?: HeadlessJobLogEntry[]; +} + /** * Agent Invocation API namespace */ export interface AgentInvocationAPI { getLogs: () => Promise; + clearLogs: () => Promise; } /** @@ -124,6 +155,8 @@ export interface McpClientConfigWriteResult { */ export interface McpServerAPI { getDetails: () => Promise; + getJobStatus: (jobId: string) => Promise; + clearLogs: () => Promise; start: () => Promise; stop: () => Promise; configureClaudeDesktop: () => Promise; diff --git a/src/common/types/events.ts b/src/common/types/events.ts index d0a1c000..0697396c 100644 --- a/src/common/types/events.ts +++ b/src/common/types/events.ts @@ -18,6 +18,7 @@ export enum ToolBoxEvent { TERMINAL_OUTPUT = "terminal:output", TERMINAL_COMMAND_COMPLETED = "terminal:command:completed", TERMINAL_ERROR = "terminal:error", + MCP_HEADLESS_JOB_UPDATED = "mcp:headless-job-updated", } /** diff --git a/src/main/index.ts b/src/main/index.ts index dbf403ec..1c11dd55 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -52,7 +52,7 @@ import { ToolManager } from "./managers/toolsManager"; import { ToolWindowManager } from "./managers/toolWindowManager"; import { TrayManager } from "./managers/trayManager"; import { VersionManager } from "./managers/versionManager"; -import { readLogEntries } from "./mcp/agentInvocationLogger"; +import { clearLogEntries, readLogEntries } from "./mcp/agentInvocationLogger"; import { McpServerManager } from "./mcp/mcpServer"; import { applyMainSentryConsent } from "./sentryRuntime"; import { ActiveToolInfo, buildToolBoxFeedbackUrl, buildToolFeedbackUrl, getEnvironmentDiagnostics, resolveActiveToolInfo } from "./utilities"; @@ -156,6 +156,15 @@ class ToolBoxApp { this.toolFilesystemAccessManager = new ToolFileSystemAccessManager(); this.mcpServerManager = new McpServerManager(7339, "127.0.0.1", this.settingsManager, this.toolManager.getRegistryManager(), this.toolManager); this.mcpServerManager.setConnectionAuthManagers(this.connectionsManager, this.authManager); + this.mcpServerManager.setJobChangeHandler((jobId) => { + if (this.mainWindow) { + this.mainWindow.webContents.send(EVENT_CHANNELS.TOOLBOX_EVENT, { + event: ToolBoxEvent.MCP_HEADLESS_JOB_UPDATED, + data: { jobId }, + timestamp: new Date().toISOString(), + }); + } + }); this.trayManager = new TrayManager( () => this.mainWindow, () => this.createWindow(), @@ -476,9 +485,15 @@ class ToolBoxApp { // Agent invocation logging handlers ipcMain.removeHandler(AGENT_INVOCATION_CHANNELS.GET_LOGS); + ipcMain.removeHandler(AGENT_INVOCATION_CHANNELS.CLEAR_LOGS); + ipcMain.removeHandler(AGENT_INVOCATION_CHANNELS.CLEAR_LOGS); // MCP server handlers ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_DETAILS); + ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_JOB_STATUS); + ipcMain.removeHandler(MCP_SERVER_CHANNELS.CLEAR_LOGS); + ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_JOB_STATUS); + ipcMain.removeHandler(MCP_SERVER_CHANNELS.CLEAR_LOGS); ipcMain.removeHandler(MCP_SERVER_CHANNELS.START); ipcMain.removeHandler(MCP_SERVER_CHANNELS.STOP); ipcMain.removeHandler(MCP_SERVER_CHANNELS.CONFIGURE_CLAUDE_DESKTOP); @@ -560,10 +575,23 @@ class ToolBoxApp { return readLogEntries(); }); + ipcMain.handle(AGENT_INVOCATION_CHANNELS.CLEAR_LOGS, () => { + clearLogEntries(); + }); + ipcMain.handle(MCP_SERVER_CHANNELS.GET_DETAILS, () => { return this.mcpServerManager.getServerDetails(); }); + ipcMain.handle(MCP_SERVER_CHANNELS.GET_JOB_STATUS, (_, jobId: string) => { + return this.mcpServerManager.getJobStatus(jobId); + }); + + ipcMain.handle(MCP_SERVER_CHANNELS.CLEAR_LOGS, () => { + clearLogEntries(); + this.mcpServerManager.clearLogs(); + }); + ipcMain.handle(MCP_SERVER_CHANNELS.START, async () => { await this.mcpServerManager.start(); this.trayManager?.refreshContextMenu(); diff --git a/src/main/managers/headlessToolInvocationManager.ts b/src/main/managers/headlessToolInvocationManager.ts index f157f46f..7bfa1672 100644 --- a/src/main/managers/headlessToolInvocationManager.ts +++ b/src/main/managers/headlessToolInvocationManager.ts @@ -2,6 +2,14 @@ import { randomUUID } from "crypto"; export type HeadlessJobStatus = "pending" | "in_progress" | "completed" | "failed"; +export type HeadlessJobLogLevel = "debug" | "info" | "warn" | "error"; + +export interface HeadlessJobLogEntry { + timestamp: string; + level: HeadlessJobLogLevel; + message: string; +} + export interface HeadlessJobRecord { jobId: string; toolId: string; @@ -17,6 +25,7 @@ export interface HeadlessJobRecord { }; result?: Record; error?: string; + logs?: HeadlessJobLogEntry[]; } interface StartJobOptions { @@ -27,10 +36,12 @@ interface StartJobOptions { } const DEFAULT_CLEANUP_TTL_MS = 60 * 60 * 1000; +const MAX_LOGS_PER_JOB = 200; export class HeadlessToolInvocationManager { private readonly jobs = new Map(); private readonly cleanupTimer: NodeJS.Timeout; + private onJobChanged: ((jobId: string) => void) | null = null; constructor(private readonly completedJobTtlMs = DEFAULT_CLEANUP_TTL_MS) { this.cleanupTimer = setInterval(() => this.cleanupExpiredJobs(), 60_000); @@ -41,6 +52,10 @@ export class HeadlessToolInvocationManager { clearInterval(this.cleanupTimer); } + public setJobChangeHandler(handler: ((jobId: string) => void) | null): void { + this.onJobChanged = handler; + } + public async startJob(options: StartJobOptions): Promise { const nowIso = new Date().toISOString(); const jobId = randomUUID(); @@ -56,9 +71,17 @@ export class HeadlessToolInvocationManager { percent: 0, message: "queued", }, + logs: [ + { + timestamp: nowIso, + level: "info", + message: "queued", + }, + ], }; this.jobs.set(jobId, initialRecord); + this.notifyJobChanged(jobId); void this.runJob(jobId, options.execute, options.timeoutMs); @@ -82,6 +105,41 @@ export class HeadlessToolInvocationManager { ...(message ? { message } : {}), }; this.jobs.set(jobId, job); + this.notifyJobChanged(jobId); + } + + public appendLog(jobId: string, level: HeadlessJobLogLevel, message: string): void { + const job = this.jobs.get(jobId); + if (!job) { + return; + } + + const logs = job.logs ?? []; + logs.push({ + timestamp: new Date().toISOString(), + level, + message, + }); + + if (logs.length > MAX_LOGS_PER_JOB) { + logs.splice(0, logs.length - MAX_LOGS_PER_JOB); + } + + job.logs = logs; + this.jobs.set(jobId, job); + this.notifyJobChanged(jobId); + } + + public clearLogs(): void { + for (const [jobId, job] of this.jobs.entries()) { + if (!job.logs || job.logs.length === 0) { + continue; + } + + job.logs = []; + this.jobs.set(jobId, job); + this.notifyJobChanged(jobId); + } } private async runJob(jobId: string, execute: (jobId: string) => Promise>, timeoutMs: number): Promise { @@ -97,6 +155,7 @@ export class HeadlessToolInvocationManager { message: "running", }; this.jobs.set(jobId, existing); + this.appendLog(jobId, "info", "running"); try { const result = await this.withTimeout(execute(jobId), timeoutMs); @@ -113,6 +172,8 @@ export class HeadlessToolInvocationManager { }; completed.result = result; this.jobs.set(jobId, completed); + this.appendLog(jobId, "info", "completed"); + this.notifyJobChanged(jobId); } catch (error) { const failed = this.jobs.get(jobId); if (!failed) { @@ -127,9 +188,15 @@ export class HeadlessToolInvocationManager { }; failed.error = error instanceof Error ? error.message : String(error); this.jobs.set(jobId, failed); + this.appendLog(jobId, "error", failed.error); + this.notifyJobChanged(jobId); } } + private notifyJobChanged(jobId: string): void { + this.onJobChanged?.(jobId); + } + private withTimeout(promise: Promise, timeoutMs: number): Promise { let timeoutHandle: NodeJS.Timeout | undefined; diff --git a/src/main/mcp/agentInvocationLogger.ts b/src/main/mcp/agentInvocationLogger.ts index 8a34bcc9..65683f7a 100644 --- a/src/main/mcp/agentInvocationLogger.ts +++ b/src/main/mcp/agentInvocationLogger.ts @@ -84,12 +84,19 @@ export function readLogEntries(): AgentInvocationLogEntry[] { const logPath = getLogFilePath(); try { - if (!fs.existsSync(logPath)) { + const candidatePaths = [logPath, ...Array.from({ length: MAX_BACKUP_COUNT }, (_, idx) => `${logPath}.${idx + 1}`)]; + const existingPaths = candidatePaths.filter((candidatePath) => fs.existsSync(candidatePath)); + + if (existingPaths.length === 0) { return []; } - const content = fs.readFileSync(logPath, { encoding: "utf-8" }); - const lines = content.split("\n").filter((line) => line.trim().length > 0); + const lines: string[] = []; + for (const existingPath of existingPaths) { + const content = fs.readFileSync(existingPath, { encoding: "utf-8" }); + const parsedLines = content.split("\n").filter((line) => line.trim().length > 0); + lines.push(...parsedLines); + } return lines .map((line) => { diff --git a/src/main/mcp/headlessToolRuntime.ts b/src/main/mcp/headlessToolRuntime.ts index ff4a71f8..62357ca6 100644 --- a/src/main/mcp/headlessToolRuntime.ts +++ b/src/main/mcp/headlessToolRuntime.ts @@ -27,7 +27,9 @@ export interface HeadlessInvokeContext { connectionName?: string; updateProgress: (percent: number, message?: string) => void; logger: { + debug: (message: string) => void; info: (message: string) => void; + warn: (message: string) => void; error: (message: string) => void; }; } diff --git a/src/main/mcp/mcpServer.ts b/src/main/mcp/mcpServer.ts index f961071a..06ecc068 100644 --- a/src/main/mcp/mcpServer.ts +++ b/src/main/mcp/mcpServer.ts @@ -18,7 +18,7 @@ import { ToolManager } from "../managers/toolsManager"; import { ToolWindowManager } from "../managers/toolWindowManager"; import { logInvocation } from "./agentInvocationLogger"; import { AgentExecutionMode, AgentInvocationMode, AgentTool, getAgentInvokableTools, resolveToolId } from "./agentToolRegistry"; -import { createHeadlessLogger, invokeHeadlessTool } from "./headlessToolRuntime"; +import { invokeHeadlessTool } from "./headlessToolRuntime"; import { JsonObjectSchema } from "./schemaConverter"; const MCP_AUTH_HEADER = "x-mcp-auth-token"; @@ -318,6 +318,10 @@ export class McpServerManager { this.powerPlatformManager = new PowerPlatformManager(connectionsManager, authManager); } + setJobChangeHandler(handler: ((jobId: string) => void) | null): void { + this.headlessInvocationManager.setJobChangeHandler(handler); + } + isRunning(): boolean { return this.httpServer !== null; } @@ -336,6 +340,14 @@ export class McpServerManager { }; } + getJobStatus(jobId: string): HeadlessJobRecord | null { + return this.headlessInvocationManager.getJob(jobId); + } + + clearLogs(): void { + this.headlessInvocationManager.clearLogs(); + } + async configureClient(client: SupportedClient): Promise { const resolvedOs = this.resolveHostOS(); const filePath = this.getClientConfigPath(client, resolvedOs); @@ -627,7 +639,9 @@ export class McpServerManager { } else if (connection.refreshToken) { authResult = await this.authManager.refreshAccessToken(connection, connection.refreshToken); } else { - throw new Error(`Interactive connection '${connection.name}' has no reusable session. Reconnect this connection from UI first, then retry headless invocation.`); + // An agent-specified connection name should be able to trigger a fresh interactive sign-in + // when there is no reusable session saved for that headless invocation. + authResult = await this.authManager.authenticateInteractive(connection); } break; case "connectionString": @@ -915,6 +929,13 @@ export class McpServerManager { toolName: displayName, timeoutMs: effectiveTimeoutMs, execute: async (jobId) => { + const jobLogger = { + debug: (message: string) => this.headlessInvocationManager.appendLog(jobId, "debug", message), + info: (message: string) => this.headlessInvocationManager.appendLog(jobId, "info", message), + warn: (message: string) => this.headlessInvocationManager.appendLog(jobId, "warn", message), + error: (message: string) => this.headlessInvocationManager.appendLog(jobId, "error", message), + }; + const result = await invokeHeadlessTool( executionManifest, prefillData, @@ -929,7 +950,7 @@ export class McpServerManager { updateProgress: (percent, message) => { this.headlessInvocationManager.updateProgress(jobId, percent, message); }, - logger: createHeadlessLogger(toolId), + logger: jobLogger, }, { settingsManager: this.settingsManager, diff --git a/src/main/preload.ts b/src/main/preload.ts index 0408540a..feec05b5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -417,11 +417,14 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // Agent invocation logging - Only for PPTB UI agentInvocation: { getLogs: () => ipcRenderer.invoke(AGENT_INVOCATION_CHANNELS.GET_LOGS), + clearLogs: () => ipcRenderer.invoke(AGENT_INVOCATION_CHANNELS.CLEAR_LOGS), }, // MCP server details - Only for PPTB UI mcpServer: { getDetails: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.GET_DETAILS), + getJobStatus: (jobId: string) => ipcRenderer.invoke(MCP_SERVER_CHANNELS.GET_JOB_STATUS, jobId), + clearLogs: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.CLEAR_LOGS), start: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.START), stop: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.STOP), configureClaudeDesktop: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.CONFIGURE_CLAUDE_DESKTOP), diff --git a/src/renderer/modals/mcpInvocationDetails/controller.ts b/src/renderer/modals/mcpInvocationDetails/controller.ts new file mode 100644 index 00000000..5232c1a9 --- /dev/null +++ b/src/renderer/modals/mcpInvocationDetails/controller.ts @@ -0,0 +1,64 @@ +import { closeModal, openModal } from "../../modules/modalManagement"; + +export const MCP_INVOCATION_MODAL_ID = "mcp-invocation-modal"; +export const MCP_INVOCATION_MODAL_CLOSE_ID = "mcp-invocation-modal-close"; +export const MCP_INVOCATION_MODAL_CONTENT_ID = "mcp-invocation-modal-content"; + +let controllerBound = false; +let closeHandler: (() => void) | null = null; + +export function initializeMcpInvocationDetailsModalController(onClose: () => void): void { + closeHandler = onClose; + if (controllerBound) { + return; + } + + const modal = document.getElementById(MCP_INVOCATION_MODAL_ID) as HTMLDivElement | null; + const closeButton = document.getElementById(MCP_INVOCATION_MODAL_CLOSE_ID) as HTMLButtonElement | null; + + if (!modal || !closeButton) { + return; + } + + closeButton.addEventListener("click", () => { + hideMcpInvocationDetailsModal(); + }); + + modal.addEventListener("click", (event) => { + if (event.target === modal) { + hideMcpInvocationDetailsModal(); + } + }); + + controllerBound = true; +} + +export function showMcpInvocationDetailsModal(): void { + openModal(MCP_INVOCATION_MODAL_ID); +} + +export function hideMcpInvocationDetailsModal(): void { + closeModal(MCP_INVOCATION_MODAL_ID); + closeHandler?.(); +} + +export function isMcpInvocationDetailsModalOpen(): boolean { + const modal = document.getElementById(MCP_INVOCATION_MODAL_ID); + return Boolean(modal?.classList.contains("active")); +} + +export function setMcpInvocationDetailsModalContent(contentHtml: string): void { + const content = document.getElementById(MCP_INVOCATION_MODAL_CONTENT_ID); + if (!content) { + return; + } + + const scrollTop = content.scrollTop; + const scrollLeft = content.scrollLeft; + content.innerHTML = contentHtml; + + window.requestAnimationFrame(() => { + content.scrollTop = scrollTop; + content.scrollLeft = scrollLeft; + }); +} diff --git a/src/renderer/modals/mcpInvocationDetails/view.ts b/src/renderer/modals/mcpInvocationDetails/view.ts new file mode 100644 index 00000000..e48e238c --- /dev/null +++ b/src/renderer/modals/mcpInvocationDetails/view.ts @@ -0,0 +1,131 @@ +import type { AgentInvocationLogEntry, HeadlessJobDetails } from "../../../common/types"; + +function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function formatTimestamp(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleString([], { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return timestamp; + } +} + +function getStatusBadgeStyle(status: string): string { + switch (status) { + case "completed": + return "background: #107c10; color: white;"; + case "failed": + return "background: #d13438; color: white;"; + case "in_progress": + return "background: #0f6cbd; color: white;"; + case "pending": + return "background: #8a8886; color: white;"; + default: + return "background: #6b6b6b; color: white;"; + } +} + +function getLogLevelBadgeStyle(level: string): string { + switch (level) { + case "error": + return "background: #d13438; color: white;"; + case "warn": + return "background: #f7630c; color: white;"; + case "info": + return "background: #0f6cbd; color: white;"; + case "debug": + return "background: #8a8886; color: white;"; + default: + return "background: #6b6b6b; color: white;"; + } +} + +export function getMcpInvocationDetailsModalView(): string { + return ` + + `; +} + +export function getMcpInvocationDetailsContent(logEntry: AgentInvocationLogEntry | null, job: HeadlessJobDetails | null): string { + if (!logEntry && !job) { + return `

No invocation details are available.

`; + } + + const status = job?.status ?? (logEntry?.outcome === "completed" ? "completed" : logEntry?.outcome === "rejected" ? "failed" : "pending"); + const logs = job?.logs ?? []; + const progressText = job?.progress ? `${job.progress.percent}%${job.progress.message ? ` - ${escapeHtml(job.progress.message)}` : ""}` : "-"; + + const detailsSummary = ` +
+
Tool
${escapeHtml(logEntry?.toolName ?? job?.toolName ?? "Unknown")}
+
Job / Correlation ID
${escapeHtml(job?.jobId ?? logEntry?.correlationId ?? "-")}
+
Status
${escapeHtml(status)}
+
Progress
${progressText}
+
Invocation Mode
${escapeHtml(logEntry?.invocationMode ?? "-")}
+
Outcome
${escapeHtml(logEntry?.outcome ?? "-")}
+
+ `; + + const detailsLogs = + logs.length > 0 + ? `
+ ${logs + .map( + (entry) => ` +
+
+ ${escapeHtml(entry.level)} + ${formatTimestamp(entry.timestamp)} +
+
${escapeHtml(entry.message)}
+
+ `, + ) + .join("")} +
` + : `

No tool log messages were captured for this job.

`; + + const resolvedError = job?.error ?? logEntry?.error; + + const resultBlock = resolvedError + ? `
${escapeHtml(resolvedError)}
` + : job?.result + ? `
${escapeHtml(JSON.stringify(job.result, null, 2))}
` + : `

No final result or error is available yet.

`; + + return ` +
+
Selected Job
+
${detailsSummary}
+
+
+
Tool Logs
+

Messages captured from the headless runtime for this job.

+
${detailsLogs}
+
+
+
Result / Error
+
${resultBlock}
+
+ `; +} diff --git a/src/renderer/modules/mcpManagement.ts b/src/renderer/modules/mcpManagement.ts index a3388551..30db0a2a 100644 --- a/src/renderer/modules/mcpManagement.ts +++ b/src/renderer/modules/mcpManagement.ts @@ -1,6 +1,21 @@ import { logError } from "../../common/logger"; +import { ToolBoxEvent } from "../../common/types"; +import { + hideMcpInvocationDetailsModal, + initializeMcpInvocationDetailsModalController, + isMcpInvocationDetailsModalOpen, + setMcpInvocationDetailsModalContent, + showMcpInvocationDetailsModal, +} from "../modals/mcpInvocationDetails/controller"; +import { getMcpInvocationDetailsContent, getMcpInvocationDetailsModalView } from "../modals/mcpInvocationDetails/view"; import { openLocalPageAsTab, registerCloseGuard } from "./toolManagement"; +const MCP_REFRESH_INTERVAL_MS = 2000; + +let mcpRefreshTimer: number | null = null; +let mcpLiveUpdatesBound = false; +let activeDetailsCorrelationId: string | null = null; + /** * Render the MCP server content into a panel */ @@ -116,32 +131,52 @@ export function renderMCPServerContent(panel: HTMLElement): void {
-
+
+
+ Log Maintenance +

Clear the invocation history and captured tool logs for this MCP server.

+
+
+
+ +
+ +
+
+ +
+

Invocations

- + - - - - - + + + - +
+ + ${getMcpInvocationDetailsModalView()}
`; + initializeMcpInvocationDetailsModalController(() => { + activeDetailsCorrelationId = null; + }); + // Load and render logs + wireMcpLiveUpdates(); loadAndRenderLogs(); + startMcpRefreshLoop(); } /** @@ -175,8 +210,21 @@ function updateMcpServerStatusUi(isRunning: boolean): void { } } +function wireMcpLiveUpdates(): void { + if (mcpLiveUpdatesBound) { + return; + } + + mcpLiveUpdatesBound = true; + window.toolboxAPI.events.on((_, payload) => { + if (payload && typeof payload === "object" && (payload as { event?: string }).event === ToolBoxEvent.MCP_HEADLESS_JOB_UPDATED) { + void loadAndRenderLogs(); + } + }); +} + /** - * Load and render the logs + * Load and render the logs. */ async function loadAndRenderLogs(): Promise { try { @@ -185,11 +233,15 @@ async function loadAndRenderLogs(): Promise { const emptyState = document.getElementById("mcp-empty"); const table = document.getElementById("invocation-logs-table"); const tbody = document.getElementById("invocation-logs-tbody"); + const clearLogsButton = document.getElementById("mcp-clear-logs-btn") as HTMLButtonElement | null; + const clearLogsStatus = document.getElementById("mcp-clear-logs-status") as HTMLDivElement | null; const addressInput = document.getElementById("mcp-server-address") as HTMLInputElement | null; const headerNameInput = document.getElementById("mcp-auth-header-name") as HTMLInputElement | null; const headerValueInput = document.getElementById("mcp-auth-header-value") as HTMLInputElement | null; - if (!container || !emptyState || !table || !tbody) return; + if (!container || !emptyState || !table || !tbody) { + return; + } if (addressInput) { addressInput.value = serverDetails.address; @@ -208,34 +260,62 @@ async function loadAndRenderLogs(): Promise { wireMcpServerToggleButton(); wireKeepMcpServerRunningToggle(serverDetails.isRunning, Boolean(userSettings.keepMcpServerRunning)); wireClientConfigButtons(); + wireClearLogsButton(clearLogsButton, clearLogsStatus); + wireInvocationTableInteractions(); if (logs.length === 0) { emptyState.style.display = "block"; table.style.display = "none"; + tbody.innerHTML = ""; + activeDetailsCorrelationId = null; return; } emptyState.style.display = "none"; table.style.display = "table"; + const jobStatuses = await Promise.all( + logs.map((log) => { + if (!log.correlationId) { + return Promise.resolve(null); + } + + return window.toolboxAPI.mcpServer.getJobStatus(log.correlationId).catch(() => null); + }), + ); + tbody.innerHTML = logs - .map( - (log) => ` + .map((log, index) => { + const job = jobStatuses[index]; + const status = job?.status ?? (log.outcome === "completed" ? "completed" : log.outcome === "rejected" ? "failed" : "no-result"); + return ` ${formatTimestamp(log.timestamp)} ${escapeHtml(log.toolName)} - ${escapeHtml(log.toolId)} - ${log.invocationMode ? escapeHtml(log.invocationMode) : ''} - ${log.connectionId ? escapeHtml(log.connectionId) : ''} - ${escapeHtml(log.prefillSummary)} + ${escapeHtml(status)} ${escapeHtml(log.outcome)} ${log.error ? `` : ""} + + ${ + log.correlationId + ? `` + : '' + } + - `, - ) + `; + }) .join(""); + + if (activeDetailsCorrelationId && !isMcpInvocationDetailsModalOpen()) { + const activeLog = logs.find((log) => log.correlationId === activeDetailsCorrelationId) ?? null; + if (activeLog) { + const activeJob = await window.toolboxAPI.mcpServer.getJobStatus(activeDetailsCorrelationId).catch(() => null); + setMcpInvocationDetailsModalContent(getMcpInvocationDetailsContent(activeLog, activeJob)); + } + } } catch (error) { logError("Failed to load agent invocation logs", error); const container = document.getElementById("mcp-container"); @@ -245,6 +325,89 @@ async function loadAndRenderLogs(): Promise { } } +function wireInvocationTableInteractions(): void { + const tbody = document.getElementById("invocation-logs-tbody"); + if (!tbody || tbody.dataset.bound === "true") { + return; + } + + tbody.dataset.bound = "true"; + tbody.addEventListener("click", (event) => { + const target = event.target as HTMLElement | null; + const button = target?.closest(".mcp-invocation-details-btn") as HTMLButtonElement | null; + const correlationId = button?.dataset.correlationId; + + if (!correlationId) { + return; + } + + activeDetailsCorrelationId = correlationId; + void openInvocationDetailsModal(correlationId); + }); +} + +async function openInvocationDetailsModal(correlationId: string): Promise { + await refreshInvocationDetailsModal(correlationId); + showMcpInvocationDetailsModal(); +} + +async function refreshInvocationDetailsModal(correlationId: string): Promise { + const logs = await window.toolboxAPI.agentInvocation.getLogs(); + const logEntry = logs.find((log) => log.correlationId === correlationId) ?? null; + const job = await window.toolboxAPI.mcpServer.getJobStatus(correlationId).catch(() => null); + setMcpInvocationDetailsModalContent(getMcpInvocationDetailsContent(logEntry, job)); +} + +function wireClearLogsButton(button: HTMLButtonElement | null, status: HTMLDivElement | null): void { + if (!button || !status || button.dataset.bound === "true") { + return; + } + + button.dataset.bound = "true"; + button.addEventListener("click", () => { + void (async () => { + if (!window.confirm("Clear all MCP invocation history and captured tool logs?")) { + return; + } + + try { + button.disabled = true; + status.textContent = "Clearing logs..."; + status.style.display = "block"; + status.style.color = "var(--text-muted, rgba(0,0,0,0.65))"; + + await window.toolboxAPI.mcpServer.clearLogs(); + hideMcpInvocationDetailsModal(); + await loadAndRenderLogs(); + + status.textContent = "Logs cleared."; + await window.toolboxAPI.utils.showNotification({ + title: "MCP Logs Cleared", + body: "Invocation history and tool logs were cleared.", + type: "success", + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + status.textContent = `Failed to clear logs: ${message}`; + status.style.color = "var(--error-color, #d13438)"; + logError("Failed to clear MCP logs", error); + } finally { + button.disabled = false; + } + })(); + }); +} + +function startMcpRefreshLoop(): void { + if (mcpRefreshTimer !== null) { + window.clearInterval(mcpRefreshTimer); + } + + mcpRefreshTimer = window.setInterval(() => { + void loadAndRenderLogs(); + }, MCP_REFRESH_INTERVAL_MS); +} + function wireKeepMcpServerRunningToggle(isServerRunning: boolean, initialKeepRunning: boolean): void { const checkbox = document.getElementById("mcp-keep-running-checkbox") as HTMLInputElement | null; const status = document.getElementById("mcp-keep-running-status") as HTMLSpanElement | null; diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index 7cb97f77..c51fd295 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -6266,6 +6266,69 @@ body.dark-theme .global-search-item-badge.badge-settings { text-transform: none; } +.mcp-invocations-container { + padding: 0 24px 20px; +} + +.mcp-invocations-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary, #8a8886); + margin: 8px 0 10px; +} + +.mcp-invocation-modal { + z-index: $z-modal; + padding: 24px; +} + +.mcp-invocation-modal-content { + width: min(980px, 100%); + max-width: min(980px, 100%); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mcp-invocation-modal-header { + padding: 14px 16px; +} + +.mcp-invocation-modal-body { + overflow: auto; + padding: 16px; + display: grid; + gap: 12px; +} + +.mcp-modal-section { + border: 1px solid var(--border-color-subtle, rgba(128, 128, 128, 0.15)); + border-radius: 8px; + padding: 12px; + background: var(--bg-color); +} + +.mcp-modal-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-color); + margin-bottom: 6px; +} + +.mcp-modal-section-description { + font-size: 12px; + color: var(--text-secondary, #8a8886); + margin: 0 0 8px; + line-height: 1.4; +} + +.mcp-modal-section-content { + width: 100%; +} + /* ── Notification Bell Button ───────────────────────────────────────────── */ .footer-bell-btn { diff --git a/tests/unit/main/mcp/mcpServer.test.ts b/tests/unit/main/mcp/mcpServer.test.ts new file mode 100644 index 00000000..bb3b7d32 --- /dev/null +++ b/tests/unit/main/mcp/mcpServer.test.ts @@ -0,0 +1,57 @@ +/// + +import { McpServerManager } from "../../../../src/main/mcp/mcpServer"; + +describe("McpServerManager headless auth resolution", () => { + it("initiates interactive auth for a named connection when no reusable session exists", async () => { + const settingsManager = { + getMcpAccessToken: jest.fn().mockReturnValue("expected-token"), + } as any; + + const manager = new McpServerManager(7339, "127.0.0.1", settingsManager, { on: jest.fn() } as any, { on: jest.fn() } as any); + + const connection = { + id: "conn-1", + name: "Headless Demo", + url: "https://contoso.crm.dynamics.com", + authenticationType: "interactive", + } as any; + + const connectionsManager = { + getConnections: jest.fn().mockReturnValue([connection]), + updateConnectionTokens: jest.fn(), + } as any; + + const authManager = { + authenticateInteractive: jest.fn().mockResolvedValue({ + accessToken: "sample-access-token", + expiresOn: new Date(Date.now() + 60_000), + msalAccountId: "account-1", + }), + acquireTokenSilently: jest.fn(), + refreshAccessToken: jest.fn(), + authenticateClientSecret: jest.fn(), + authenticateUsernamePassword: jest.fn(), + } as any; + + (manager as any).connectionsManager = connectionsManager; + (manager as any).authManager = authManager; + + const result = await (manager as any).resolveHeadlessAuthContext({ connectionName: "Headless Demo" }); + + expect(authManager.authenticateInteractive).toHaveBeenCalledWith(connection); + expect(connectionsManager.updateConnectionTokens).toHaveBeenCalledWith(connection.id, { + accessToken: "sample-access-token", + refreshToken: undefined, + expiresOn: expect.any(Date), + msalAccountId: "account-1", + }); + expect(result).toMatchObject({ + authToken: "sample-access-token", + source: "connection-name", + connectionName: "Headless Demo", + connectionId: "conn-1", + connectionUrl: "https://contoso.crm.dynamics.com", + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f32a90e6..dd0c946b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,23 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "moduleResolution": "Node16", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "types": ["node"], - "allowSyntheticDefaultImports": true, - "isolatedModules": true - }, - "include": ["src/main/**/*", "src/common/**/*"], - "exclude": ["node_modules", "dist", "build", "src/renderer"] + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "Node16", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"], + "allowSyntheticDefaultImports": true, + "isolatedModules": true + }, + "include": ["src/main/**/*", "src/common/**/*"], + "exclude": ["node_modules", "dist", "build", "src/renderer"] } From 9021deae336cdbf12e98294ca06b74723fdf46d3 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sat, 15 Aug 2026 18:51:14 -0400 Subject: [PATCH 3/6] feat(mcp): add client configuration status retrieval and enhance job management --- src/common/ipc/channels.ts | 1 + src/common/types/api.ts | 11 ++- src/main/index.ts | 22 ++++- .../managers/headlessToolInvocationManager.ts | 6 ++ src/main/mcp/agentInvocationLogger.ts | 11 ++- src/main/mcp/mcpServer.ts | 68 +++++++++++--- src/main/preload.ts | 1 + src/renderer/modules/mcpManagement.ts | 76 ++++++++++----- src/renderer/styles.scss | 5 + .../headlessToolInvocationManager.test.ts | 29 ++++++ tests/unit/main/mcp/mcpServer.test.ts | 94 ++++++++++++++++++- 11 files changed, 275 insertions(+), 49 deletions(-) create mode 100644 tests/unit/main/managers/headlessToolInvocationManager.test.ts diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index f5d14536..7b86de9d 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -253,6 +253,7 @@ export const AGENT_INVOCATION_CHANNELS = { // MCP server status/details channels export const MCP_SERVER_CHANNELS = { GET_DETAILS: "mcp-server:get-details", + GET_CLIENT_CONFIG_STATUSES: "mcp-server:get-client-config-statuses", GET_JOB_STATUS: "mcp-server:get-job-status", CLEAR_LOGS: "mcp-server:clear-logs", START: "mcp-server:start", diff --git a/src/common/types/api.ts b/src/common/types/api.ts index 32695787..06f6c19d 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -89,9 +89,9 @@ export interface AgentInvocationLogEntry { toolName: string; connectionId: string | null; prefillSummary: string; - outcome: "completed" | "no-result" | "rejected"; + outcome: "in-progress" | "completed" | "no-result" | "rejected"; invocationMode?: "one-way" | "two-way"; - correlationId?: string; + correlationId: string; error?: string; } @@ -150,11 +150,18 @@ export interface McpClientConfigWriteResult { serverName: string; } +export interface McpClientConfigStatus { + client: "claude-desktop" | "vscode"; + status: "connected" | "not-configured" | "invalid"; + filePath: string; +} + /** * MCP server API namespace */ export interface McpServerAPI { getDetails: () => Promise; + getClientConfigStatuses: () => Promise; getJobStatus: (jobId: string) => Promise; clearLogs: () => Promise; start: () => Promise; diff --git a/src/main/index.ts b/src/main/index.ts index 1c11dd55..d6825a1c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -490,6 +490,7 @@ class ToolBoxApp { // MCP server handlers ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_DETAILS); + ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_CLIENT_CONFIG_STATUSES); ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_JOB_STATUS); ipcMain.removeHandler(MCP_SERVER_CHANNELS.CLEAR_LOGS); ipcMain.removeHandler(MCP_SERVER_CHANNELS.GET_JOB_STATUS); @@ -572,7 +573,22 @@ class ToolBoxApp { // Agent invocation logs (main UI only) ipcMain.handle(AGENT_INVOCATION_CHANNELS.GET_LOGS, () => { - return readLogEntries(); + const persistedLogs = readLogEntries(); + const persistedCorrelationIds = new Set(persistedLogs.flatMap((entry) => (entry.correlationId ? [entry.correlationId] : []))); + const activeLogs = this.mcpServerManager + .getActiveJobs() + .filter((job) => !persistedCorrelationIds.has(job.jobId)) + .map((job) => ({ + timestamp: job.createdAt, + toolId: job.toolId, + toolName: job.toolName, + connectionId: null, + prefillSummary: "", + outcome: "in-progress" as const, + correlationId: job.jobId, + })); + + return [...activeLogs, ...persistedLogs].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); }); ipcMain.handle(AGENT_INVOCATION_CHANNELS.CLEAR_LOGS, () => { @@ -583,6 +599,10 @@ class ToolBoxApp { return this.mcpServerManager.getServerDetails(); }); + ipcMain.handle(MCP_SERVER_CHANNELS.GET_CLIENT_CONFIG_STATUSES, async () => { + return await this.mcpServerManager.getClientConfigStatuses(); + }); + ipcMain.handle(MCP_SERVER_CHANNELS.GET_JOB_STATUS, (_, jobId: string) => { return this.mcpServerManager.getJobStatus(jobId); }); diff --git a/src/main/managers/headlessToolInvocationManager.ts b/src/main/managers/headlessToolInvocationManager.ts index 7bfa1672..8aaca38f 100644 --- a/src/main/managers/headlessToolInvocationManager.ts +++ b/src/main/managers/headlessToolInvocationManager.ts @@ -93,6 +93,12 @@ export class HeadlessToolInvocationManager { return job ? { ...job } : null; } + public getActiveJobs(): HeadlessJobRecord[] { + return Array.from(this.jobs.values()) + .filter((job) => job.status === "pending" || job.status === "in_progress") + .map((job) => ({ ...job })); + } + public updateProgress(jobId: string, percent: number, message?: string): void { const job = this.jobs.get(jobId); if (!job || (job.status !== "pending" && job.status !== "in_progress")) { diff --git a/src/main/mcp/agentInvocationLogger.ts b/src/main/mcp/agentInvocationLogger.ts index 65683f7a..b8f29994 100644 --- a/src/main/mcp/agentInvocationLogger.ts +++ b/src/main/mcp/agentInvocationLogger.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from "crypto"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -26,7 +27,7 @@ export interface AgentInvocationLogEntry { prefillSummary: string; outcome: InvocationOutcome; invocationMode?: "one-way" | "two-way"; - correlationId?: string; + correlationId: string; error?: string; } @@ -101,7 +102,11 @@ export function readLogEntries(): AgentInvocationLogEntry[] { return lines .map((line) => { try { - return JSON.parse(line) as AgentInvocationLogEntry; + const entry = JSON.parse(line) as AgentInvocationLogEntry; + return { + ...entry, + correlationId: entry.correlationId || `log-${createHash("sha256").update(line).digest("hex").slice(0, 16)}`, + }; } catch { return null; } @@ -232,8 +237,8 @@ export function logInvocation(params: { connectionId: params.connectionId ? REDACTED_VALUE : null, prefillSummary: getPrefillSummary(params.prefillData), outcome: params.outcome, + correlationId: params.correlationId ?? randomUUID(), ...(params.invocationMode ? { invocationMode: params.invocationMode } : {}), - ...(params.correlationId ? { correlationId: params.correlationId } : {}), ...(params.error ? { error: params.error } : {}), }; diff --git a/src/main/mcp/mcpServer.ts b/src/main/mcp/mcpServer.ts index 06ecc068..dad6e2cc 100644 --- a/src/main/mcp/mcpServer.ts +++ b/src/main/mcp/mcpServer.ts @@ -5,8 +5,9 @@ import { promises as fs } from "fs"; import { createServer, IncomingMessage, ServerResponse } from "http"; import os from "os"; import path from "path"; +import { isDeepStrictEqual } from "util"; import { logError, logInfo } from "../../common/logger"; -import { Connection, ToolManifest } from "../../common/types"; +import { Connection, McpClientConfigStatus, ToolManifest } from "../../common/types"; import { AuthManager } from "../managers/authManager"; import { ConnectionsManager } from "../managers/connectionsManager"; import { DataverseManager } from "../managers/dataverseManager"; @@ -344,34 +345,31 @@ export class McpServerManager { return this.headlessInvocationManager.getJob(jobId); } + getActiveJobs(): HeadlessJobRecord[] { + return this.headlessInvocationManager.getActiveJobs(); + } + clearLogs(): void { this.headlessInvocationManager.clearLogs(); } + async getClientConfigStatuses(): Promise { + return await Promise.all([this.getClientConfigStatus("claude-desktop"), this.getClientConfigStatus("vscode")]); + } + async configureClient(client: SupportedClient): Promise { const resolvedOs = this.resolveHostOS(); const filePath = this.getClientConfigPath(client, resolvedOs); - const serverDetails = this.getServerDetails(); - const vscodeServerEntry = { - type: "http", - url: `${serverDetails.address}/mcp`, - headers: { - [MCP_AUTH_HEADER_DISPLAY_NAME]: serverDetails.authHeaderValue, - }, - }; - const claudeServerEntry = { - command: "npx", - args: ["-y", "mcp-remote", `${serverDetails.address}/mcp`, "--header", `${MCP_AUTH_HEADER_DISPLAY_NAME}: ${serverDetails.authHeaderValue}`], - }; + const expectedEntry = this.getExpectedClientConfig(client); const root = await this.readJsonObject(filePath); if (client === "claude-desktop") { const mcpServers = isRecord(root.mcpServers) ? root.mcpServers : {}; - mcpServers[MCP_SERVER_CONFIG_KEY] = claudeServerEntry; + mcpServers[MCP_SERVER_CONFIG_KEY] = expectedEntry; root.mcpServers = mcpServers; } else { const servers = isRecord(root.servers) ? root.servers : {}; - servers[MCP_SERVER_CONFIG_KEY] = vscodeServerEntry; + servers[MCP_SERVER_CONFIG_KEY] = expectedEntry; root.servers = servers; } @@ -393,6 +391,46 @@ export class McpServerManager { }; } + private async getClientConfigStatus(client: SupportedClient): Promise { + const filePath = this.getClientConfigPath(client, this.resolveHostOS()); + + try { + const root = await this.readJsonObject(filePath); + const serverCollection = client === "claude-desktop" ? root.mcpServers : root.servers; + if (!isRecord(serverCollection) || !(MCP_SERVER_CONFIG_KEY in serverCollection)) { + return { client, status: "not-configured", filePath }; + } + + const actualEntry = serverCollection[MCP_SERVER_CONFIG_KEY]; + const expectedEntry = this.getExpectedClientConfig(client); + return { + client, + status: isDeepStrictEqual(actualEntry, expectedEntry) ? "connected" : "invalid", + filePath, + }; + } catch { + return { client, status: "invalid", filePath }; + } + } + + private getExpectedClientConfig(client: SupportedClient): Record { + const serverDetails = this.getServerDetails(); + if (client === "claude-desktop") { + return { + command: "npx", + args: ["-y", "mcp-remote", `${serverDetails.address}/mcp`, "--header", `${MCP_AUTH_HEADER_DISPLAY_NAME}: ${serverDetails.authHeaderValue}`], + }; + } + + return { + type: "http", + url: `${serverDetails.address}/mcp`, + headers: { + [MCP_AUTH_HEADER_DISPLAY_NAME]: serverDetails.authHeaderValue, + }, + }; + } + private resolveHostOS(): SupportedOs { switch (process.platform) { case "darwin": diff --git a/src/main/preload.ts b/src/main/preload.ts index feec05b5..ffb96a3b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -423,6 +423,7 @@ contextBridge.exposeInMainWorld("toolboxAPI", { // MCP server details - Only for PPTB UI mcpServer: { getDetails: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.GET_DETAILS), + getClientConfigStatuses: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.GET_CLIENT_CONFIG_STATUSES), getJobStatus: (jobId: string) => ipcRenderer.invoke(MCP_SERVER_CHANNELS.GET_JOB_STATUS, jobId), clearLogs: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.CLEAR_LOGS), start: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.START), diff --git a/src/renderer/modules/mcpManagement.ts b/src/renderer/modules/mcpManagement.ts index 30db0a2a..53bb9980 100644 --- a/src/renderer/modules/mcpManagement.ts +++ b/src/renderer/modules/mcpManagement.ts @@ -1,5 +1,5 @@ import { logError } from "../../common/logger"; -import { ToolBoxEvent } from "../../common/types"; +import { McpClientConfigStatus, ToolBoxEvent } from "../../common/types"; import { hideMcpInvocationDetailsModal, initializeMcpInvocationDetailsModalController, @@ -125,9 +125,12 @@ export function renderMCPServerContent(panel: HTMLElement): void {
+ +
+
+
-
@@ -184,6 +187,8 @@ export function renderMCPServerContent(panel: HTMLElement): void { */ function getOutcomeBadgeStyle(outcome: string): string { switch (outcome) { + case "in-progress": + return "background: #0078d4; color: white;"; case "completed": return "background: #107c10; color: white;"; case "no-result": @@ -228,7 +233,12 @@ function wireMcpLiveUpdates(): void { */ async function loadAndRenderLogs(): Promise { try { - const [serverDetails, logs, userSettings] = await Promise.all([window.toolboxAPI.mcpServer.getDetails(), window.toolboxAPI.agentInvocation.getLogs(), window.toolboxAPI.getUserSettings()]); + const [serverDetails, clientConfigStatuses, logs, userSettings] = await Promise.all([ + window.toolboxAPI.mcpServer.getDetails(), + window.toolboxAPI.mcpServer.getClientConfigStatuses(), + window.toolboxAPI.agentInvocation.getLogs(), + window.toolboxAPI.getUserSettings(), + ]); const container = document.getElementById("mcp-container"); const emptyState = document.getElementById("mcp-empty"); const table = document.getElementById("invocation-logs-table"); @@ -259,6 +269,7 @@ async function loadAndRenderLogs(): Promise { wireCopyButton("copy-mcp-auth-header-value-btn", () => serverDetails.authHeaderValue, "MCP auth token copied"); wireMcpServerToggleButton(); wireKeepMcpServerRunningToggle(serverDetails.isRunning, Boolean(userSettings.keepMcpServerRunning)); + updateClientConfigStatusUi(clientConfigStatuses); wireClientConfigButtons(); wireClearLogsButton(clearLogsButton, clearLogsStatus); wireInvocationTableInteractions(); @@ -275,13 +286,7 @@ async function loadAndRenderLogs(): Promise { table.style.display = "table"; const jobStatuses = await Promise.all( - logs.map((log) => { - if (!log.correlationId) { - return Promise.resolve(null); - } - - return window.toolboxAPI.mcpServer.getJobStatus(log.correlationId).catch(() => null); - }), + logs.map((log) => window.toolboxAPI.mcpServer.getJobStatus(log.correlationId).catch(() => null)), ); tbody.innerHTML = logs @@ -298,11 +303,7 @@ async function loadAndRenderLogs(): Promise { ${log.error ? `` : ""} - ${ - log.correlationId - ? `` - : '' - } + `; @@ -471,9 +472,8 @@ function wireKeepMcpServerRunningToggle(isServerRunning: boolean, initialKeepRun function wireClientConfigButtons(): void { const claudeBtn = document.getElementById("connect-claude-desktop-btn") as HTMLButtonElement | null; const vscodeBtn = document.getElementById("connect-vscode-btn") as HTMLButtonElement | null; - const statusEl = document.getElementById("mcp-client-config-status") as HTMLDivElement | null; - if (!claudeBtn || !vscodeBtn || !statusEl) { + if (!claudeBtn || !vscodeBtn) { return; } @@ -482,28 +482,31 @@ function wireClientConfigButtons(): void { vscodeBtn.disabled = !enabled; }; - const showStatus = (message: string, isError: boolean): void => { + const showStatus = (target: "claude" | "vscode", message: string, isError: boolean): void => { + const statusEl = document.getElementById(target === "claude" ? "claude-desktop-config-status" : "vscode-config-status"); + if (!statusEl) { + return; + } statusEl.textContent = message; - statusEl.style.display = "block"; statusEl.style.color = isError ? "var(--error-color, #d13438)" : "var(--text-muted, rgba(0,0,0,0.65))"; }; const writeConfig = async (target: "claude" | "vscode"): Promise => { try { setButtonsEnabled(false); - showStatus(`Configuring ${target === "claude" ? "Claude Desktop" : "VSCode"}...`, false); + showStatus(target, "Updating config...", false); const result = target === "claude" ? await window.toolboxAPI.mcpServer.configureClaudeDesktop() : await window.toolboxAPI.mcpServer.configureVSCode(); - showStatus(`Updated ${target === "claude" ? "Claude Desktop" : "VSCode"} config at ${result.filePath} (${result.os}).`, false); + updateClientConfigStatusUi(await window.toolboxAPI.mcpServer.getClientConfigStatuses()); await window.toolboxAPI.utils.showNotification({ title: "MCP Config Updated", - body: `${target === "claude" ? "Claude Desktop" : "VSCode"} is now configured for ${result.serverName}.`, + body: `${target === "claude" ? "Claude Desktop" : "VSCode"} is now configured for ${result.serverName} at ${result.filePath}.`, type: "success", }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - showStatus(`Failed to configure ${target === "claude" ? "Claude Desktop" : "VSCode"}: ${message}`, true); + showStatus(target, `Config update failed: ${message}`, true); await window.toolboxAPI.utils.showNotification({ title: "MCP Config Failed", body: `Unable to configure ${target === "claude" ? "Claude Desktop" : "VSCode"}.`, @@ -530,6 +533,33 @@ function wireClientConfigButtons(): void { } } +function updateClientConfigStatusUi(statuses: McpClientConfigStatus[]): void { + for (const status of statuses) { + const isClaude = status.client === "claude-desktop"; + const button = document.getElementById(isClaude ? "connect-claude-desktop-btn" : "connect-vscode-btn") as HTMLButtonElement | null; + const statusEl = document.getElementById(isClaude ? "claude-desktop-config-status" : "vscode-config-status"); + if (!button || !statusEl) { + continue; + } + + if (status.status === "connected") { + statusEl.textContent = "Connected"; + statusEl.style.color = "#107c10"; + button.textContent = `Reconnect ${isClaude ? "Claude Desktop" : "VSCode"}`; + } else if (status.status === "invalid") { + statusEl.textContent = "Config is wrong"; + statusEl.style.color = "var(--error-color, #d13438)"; + button.textContent = `Fix ${isClaude ? "Claude Desktop" : "VSCode"} config`; + } else { + statusEl.textContent = "Not configured"; + statusEl.style.color = "var(--text-muted, rgba(0,0,0,0.65))"; + button.textContent = `Connect to ${isClaude ? "Claude Desktop" : "VSCode"}`; + } + + statusEl.title = status.filePath; + } +} + function wireMcpServerToggleButton(): void { const toggleBtn = document.getElementById("mcp-server-toggle-btn") as HTMLButtonElement | null; const actionStatus = document.getElementById("mcp-server-action-status") as HTMLSpanElement | null; diff --git a/src/renderer/styles.scss b/src/renderer/styles.scss index c51fd295..aa643f97 100644 --- a/src/renderer/styles.scss +++ b/src/renderer/styles.scss @@ -2543,10 +2543,15 @@ body.dark-theme .settings-vscode-item:hover { .mcp-client-connect-row { display: flex; + align-items: center; flex-wrap: wrap; gap: 8px; } +.mcp-client-connect-row + .mcp-client-connect-row { + margin-top: 8px; +} + .mcp-server-actions-row { display: flex; align-items: center; diff --git a/tests/unit/main/managers/headlessToolInvocationManager.test.ts b/tests/unit/main/managers/headlessToolInvocationManager.test.ts new file mode 100644 index 00000000..f2fb85dc --- /dev/null +++ b/tests/unit/main/managers/headlessToolInvocationManager.test.ts @@ -0,0 +1,29 @@ +/// + +import { HeadlessToolInvocationManager } from "../../../../src/main/managers/headlessToolInvocationManager"; + +describe("HeadlessToolInvocationManager", () => { + it("returns pending or running jobs until they complete", async () => { + const manager = new HeadlessToolInvocationManager(); + let finishJob: ((result: Record) => void) | undefined; + const execution = new Promise>((resolve) => { + finishJob = resolve; + }); + + const job = await manager.startJob({ + toolId: "sample-tool", + toolName: "Sample Tool", + timeoutMs: 1_000, + execute: () => execution, + }); + + expect(manager.getActiveJobs()).toEqual([expect.objectContaining({ jobId: job.jobId, status: "in_progress" })]); + + finishJob?.({ success: true }); + await execution; + await new Promise((resolve) => setImmediate(resolve)); + + expect(manager.getActiveJobs()).toEqual([]); + manager.dispose(); + }); +}); \ No newline at end of file diff --git a/tests/unit/main/mcp/mcpServer.test.ts b/tests/unit/main/mcp/mcpServer.test.ts index bb3b7d32..907813c3 100644 --- a/tests/unit/main/mcp/mcpServer.test.ts +++ b/tests/unit/main/mcp/mcpServer.test.ts @@ -1,14 +1,98 @@ /// import { McpServerManager } from "../../../../src/main/mcp/mcpServer"; +import { promises as fs } from "fs"; +import os from "os"; +import path from "path"; + +jest.mock("fs", () => ({ + promises: { + readFile: jest.fn(), + mkdir: jest.fn(), + writeFile: jest.fn(), + }, +})); + +jest.mock("os", () => ({ + __esModule: true, + default: { + homedir: jest.fn(), + }, +})); + +function createManager(): McpServerManager { + const settingsManager = { + getMcpAccessToken: jest.fn().mockReturnValue("expected-token"), + } as any; + + return new McpServerManager(7339, "127.0.0.1", settingsManager, { on: jest.fn() } as any, { on: jest.fn() } as any); +} + +describe("McpServerManager client configuration status", () => { + const mockedReadFile = fs.readFile as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + (os.homedir as jest.Mock).mockReturnValue("/test-home"); + }); + + it("reports connected, not configured, and invalid client configs", async () => { + const manager = createManager(); + const claudePath = path.join("/test-home", "Library", "Application Support", "Claude", "claude_desktop_config.json"); + const vscodePath = path.join("/test-home", "Library", "Application Support", "Code", "User", "mcp.json"); + + mockedReadFile.mockImplementation(async (filePath) => { + if (filePath === claudePath) { + return JSON.stringify({ + mcpServers: { + pptb: { + command: "npx", + args: ["-y", "mcp-remote", "http://127.0.0.1:7339/mcp", "--header", "X-MCP-Auth-Token: expected-token"], + }, + }, + }); + } + if (filePath === vscodePath) { + return JSON.stringify({ servers: { pptb: { type: "http", url: "http://wrong/mcp" } } }); + } + throw Object.assign(new Error("Not found"), { code: "ENOENT" }); + }); + + await expect(manager.getClientConfigStatuses()).resolves.toEqual([ + { client: "claude-desktop", status: "connected", filePath: claudePath }, + { client: "vscode", status: "invalid", filePath: vscodePath }, + ]); + + mockedReadFile.mockImplementation(async (filePath) => { + if (filePath === vscodePath) { + return JSON.stringify({ + servers: { + pptb: { + headers: { "X-MCP-Auth-Token": "expected-token" }, + url: "http://127.0.0.1:7339/mcp", + type: "http", + }, + }, + }); + } + throw Object.assign(new Error("Not found"), { code: "ENOENT" }); + }); + await expect(manager.getClientConfigStatuses()).resolves.toEqual([ + { client: "claude-desktop", status: "not-configured", filePath: claudePath }, + { client: "vscode", status: "connected", filePath: vscodePath }, + ]); + + mockedReadFile.mockRejectedValue(Object.assign(new Error("Not found"), { code: "ENOENT" })); + await expect(manager.getClientConfigStatuses()).resolves.toEqual([ + { client: "claude-desktop", status: "not-configured", filePath: claudePath }, + { client: "vscode", status: "not-configured", filePath: vscodePath }, + ]); + }); +}); describe("McpServerManager headless auth resolution", () => { it("initiates interactive auth for a named connection when no reusable session exists", async () => { - const settingsManager = { - getMcpAccessToken: jest.fn().mockReturnValue("expected-token"), - } as any; - - const manager = new McpServerManager(7339, "127.0.0.1", settingsManager, { on: jest.fn() } as any, { on: jest.fn() } as any); + const manager = createManager(); const connection = { id: "conn-1", From ac2492591556fb7b3fa876af4d141d29290bb4a1 Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sat, 15 Aug 2026 18:52:07 -0400 Subject: [PATCH 4/6] fix: remove unnecessary line breaks in log rendering and test files --- src/renderer/modules/mcpManagement.ts | 4 +--- .../unit/main/managers/headlessToolInvocationManager.test.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/renderer/modules/mcpManagement.ts b/src/renderer/modules/mcpManagement.ts index 53bb9980..82330665 100644 --- a/src/renderer/modules/mcpManagement.ts +++ b/src/renderer/modules/mcpManagement.ts @@ -285,9 +285,7 @@ async function loadAndRenderLogs(): Promise { emptyState.style.display = "none"; table.style.display = "table"; - const jobStatuses = await Promise.all( - logs.map((log) => window.toolboxAPI.mcpServer.getJobStatus(log.correlationId).catch(() => null)), - ); + const jobStatuses = await Promise.all(logs.map((log) => window.toolboxAPI.mcpServer.getJobStatus(log.correlationId).catch(() => null))); tbody.innerHTML = logs .map((log, index) => { diff --git a/tests/unit/main/managers/headlessToolInvocationManager.test.ts b/tests/unit/main/managers/headlessToolInvocationManager.test.ts index f2fb85dc..e5c520e3 100644 --- a/tests/unit/main/managers/headlessToolInvocationManager.test.ts +++ b/tests/unit/main/managers/headlessToolInvocationManager.test.ts @@ -26,4 +26,4 @@ describe("HeadlessToolInvocationManager", () => { expect(manager.getActiveJobs()).toEqual([]); manager.dispose(); }); -}); \ No newline at end of file +}); From 49bd5683ac2d1a3e6ee0fb9458ece767ed52a0cc Mon Sep 17 00:00:00 2001 From: Power-Maverick Date: Sat, 15 Aug 2026 18:58:48 -0400 Subject: [PATCH 5/6] refactor: remove MCP preview UI checks and simplify related logic --- src/renderer/index.html | 2 +- src/renderer/modules/homepageManagement.ts | 7 ++---- src/renderer/modules/marketplaceManagement.ts | 18 ++++---------- .../modules/previewFeatureManagement.ts | 24 ++----------------- src/renderer/modules/settingsManagement.ts | 18 ++++++++++---- .../modules/toolsSidebarManagement.ts | 15 +++--------- 6 files changed, 26 insertions(+), 58 deletions(-) diff --git a/src/renderer/index.html b/src/renderer/index.html index 49213676..c4eed520 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -40,7 +40,7 @@