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..7b86de9d 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -247,11 +247,15 @@ 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_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", 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..06f6c19d 100644 --- a/src/common/types/api.ts +++ b/src/common/types/api.ts @@ -89,17 +89,48 @@ 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; } +/** + * 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; } /** @@ -119,11 +150,20 @@ 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; 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/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..d6825a1c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -52,10 +52,10 @@ 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 { 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(); @@ -147,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(), @@ -277,6 +295,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}`, @@ -462,9 +485,16 @@ 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_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); + 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); @@ -509,7 +539,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 +554,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, + ); } }); @@ -537,13 +573,45 @@ 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, () => { + clearLogEntries(); }); ipcMain.handle(MCP_SERVER_CHANNELS.GET_DETAILS, () => { 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); + }); + + 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(); @@ -2940,6 +3008,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 +3032,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 +3070,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/headlessToolInvocationManager.ts b/src/main/managers/headlessToolInvocationManager.ts index f157f46f..8aaca38f 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); @@ -70,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")) { @@ -82,6 +111,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 +161,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 +178,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 +194,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/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/agentInvocationLogger.ts b/src/main/mcp/agentInvocationLogger.ts index 8a34bcc9..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; } @@ -84,17 +85,28 @@ 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) => { 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; } @@ -225,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/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..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; }; } @@ -204,7 +206,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..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 } 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"; @@ -18,7 +19,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"; @@ -290,6 +291,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 { @@ -303,6 +319,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; } @@ -321,30 +341,35 @@ export class McpServerManager { }; } + getJobStatus(jobId: string): HeadlessJobRecord | null { + 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; } @@ -366,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": @@ -419,11 +484,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; @@ -566,7 +677,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": @@ -815,10 +928,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, @@ -854,8 +967,15 @@ 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( - installedManifest, + executionManifest, prefillData, { toolId, @@ -868,7 +988,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..ffb96a3b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -417,11 +417,15 @@ 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), + 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), stop: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.STOP), configureClaudeDesktop: () => ipcRenderer.invoke(MCP_SERVER_CHANNELS.CONFIGURE_CLAUDE_DESKTOP), 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 @@