From 43c0ffe62993e2c033772977db9634b5e469cac4 Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Fri, 2 Oct 2026 01:56:26 -0400 Subject: [PATCH] feat: "Connect an AI Agent" dialog and MCP status bar item Connecting a client meant finding a config that was only printed to the dev console, and the Preferences > MCP tab still pointed at the legacy `/sse` endpoint without a token. Connecting is the point of Arduino Agent, so it gets a first-class place in the IDE: - Help > Connect an AI Agent... (also in the command palette) opens a dialog with copy buttons for: a `claude mcp add` command, an MCP config for Claude Desktop/Cursor/etc. via the bundled stdio bridge, and direct HTTP. The snippets use this installation's real paths: the bridge in resources/mcp-bridge (or the checkout's bridge/ in dev builds), the app executable as ARDUINO_AGENT_PATH, and the token. - A status bar item shows "MCP ready", "Agent connected" (a session made a request in the last 5 minutes), "MCP off" or "MCP unavailable" with the reason (e.g. its port is taken by another copy). Clicking it opens the dialog. - The first time the server is running, a notification offers the dialog. - Backend: MCPService.getConnectionGuide(); MCPStatus gains activeClients (per-session last-request times; Streamable HTTP sessions rarely close, so connectedClients only grows) and error (why start() failed); session open/close now pushes a status change to the frontend. - Preferences > MCP shows the /mcp address and points at the dialog. Co-Authored-By: Claude Opus 5.5 --- .../dialogs/settings/settings-component.tsx | 10 +- .../browser/arduino-mcp-frontend-module.ts | 7 + .../src/browser/connect-agent.ts | 235 ++++++++++++++++++ .../src/browser/mcp-frontend-contribution.ts | 6 + .../src/common/mcp-service.ts | 36 +++ arduino-mcp-extension/src/node/mcp-server.ts | 45 ++++ .../src/node/mcp-service-impl.ts | 85 +++++++ 7 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 arduino-mcp-extension/src/browser/connect-agent.ts diff --git a/arduino-ide-extension/src/browser/dialogs/settings/settings-component.tsx b/arduino-ide-extension/src/browser/dialogs/settings/settings-component.tsx index 2724f458d84..203c586fe76 100644 --- a/arduino-ide-extension/src/browser/dialogs/settings/settings-component.tsx +++ b/arduino-ide-extension/src/browser/dialogs/settings/settings-component.tsx @@ -538,7 +538,7 @@ export class SettingsComponent extends React.Component<

{nls.localize( 'arduino/preferences/mcp.description', - 'MCP enables AI assistants like Claude Code to interact with the Arduino IDE - compiling sketches, uploading code, managing libraries, and more.' + 'MCP enables AI assistants like Claude Code to work in Arduino Agent with you - compiling sketches, uploading code, managing libraries, and more.' )}

@@ -618,12 +618,16 @@ export class SettingsComponent extends React.Component<
+ {/* Arduino Agent: the streamable HTTP endpoint (SSE is legacy) and the setup dialog */}
- {nls.localize('arduino/preferences/mcp.connectionInfo', 'To connect Claude Code, add to your MCP configuration:')} + {nls.localize('arduino/preferences/mcp.serverAddress', 'MCP server address:')}
- http://127.0.0.1:{this.state.mcpPort}/sse + http://127.0.0.1:{this.state.mcpPort}/mcp +
+ {nls.localize('arduino/preferences/mcp.connectHint', 'For ready-to-paste setups for Claude Code, Claude Desktop and other clients, use Help > Connect an AI Agent...')} +
); diff --git a/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts b/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts index 6e3fe3714bb..a6e2aff753b 100644 --- a/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts +++ b/arduino-mcp-extension/src/browser/arduino-mcp-frontend-module.ts @@ -18,6 +18,7 @@ import { bindMCPPreferences, MCPPreferences } from './mcp-preferences'; import { MCPService, MCPServicePath } from '../common/mcp-service'; import { MCPFrontendContribution } from './mcp-frontend-contribution'; import { AgentAbout, DisabledIDEUpdateCheck, RemoveIDEUpdaterCommand } from './agent-branding'; +import { ConnectAgentContribution } from './connect-agent'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Bind MCP preferences to add settings to the IDE preferences panel @@ -40,5 +41,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MCPFrontendContribution).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(MCPFrontendContribution); + // Help > Connect an AI Agent..., the MCP status bar item, first-run hint + bind(ConnectAgentContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(ConnectAgentContribution); + bind(MenuContribution).toService(ConnectAgentContribution); + bind(FrontendApplicationContribution).toService(ConnectAgentContribution); + console.log('[arduino-mcp] Frontend module loaded with MCP preferences and service proxy'); }); diff --git a/arduino-mcp-extension/src/browser/connect-agent.ts b/arduino-mcp-extension/src/browser/connect-agent.ts new file mode 100644 index 00000000000..38fe900e1b7 --- /dev/null +++ b/arduino-mcp-extension/src/browser/connect-agent.ts @@ -0,0 +1,235 @@ +/** + * "Connect an AI Agent": the in-IDE way to hook up Claude Code, Claude Desktop + * or any MCP client. + * + * - Help > Connect an AI Agent... opens a dialog with copy-ready setups for this + * installation (real paths of the bundled bridge and of the app, the token). + * - A status bar item shows whether the MCP server is up and whether an agent + * has used it recently; clicking it opens the same dialog. + * - The first time the server is running, a notification points at the dialog. + */ + +import { AbstractDialog } from '@theia/core/lib/browser/dialogs'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution'; +import { CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution'; +import { StatusBar, StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar'; +import { StorageService } from '@theia/core/lib/browser/storage-service'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; +import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + MCPConnectionGuide, + MCPConnectionSnippet, + MCPService, + MCPStatus, +} from '../common/mcp-service'; +import { MCPFrontendContribution } from './mcp-frontend-contribution'; + +export namespace ConnectAgentCommands { + export const OPEN: Command = { + id: 'arduino-agent.connect-ai-agent', + label: 'Connect an AI Agent...', + category: 'Arduino Agent', + }; +} + +const STATUS_BAR_ID = 'arduino-agent-mcp-status'; +const STATUS_POLL_MS = 20_000; +const HINT_SHOWN_KEY = 'arduino-agent.connect-hint-shown'; + +@injectable() +export class ConnectAgentContribution + implements CommandContribution, MenuContribution, FrontendApplicationContribution +{ + @inject(MCPService) + private readonly mcpService!: MCPService; + + @inject(MCPFrontendContribution) + private readonly mcpFrontend!: MCPFrontendContribution; + + @inject(StatusBar) + private readonly statusBar!: StatusBar; + + @inject(ClipboardService) + private readonly clipboard!: ClipboardService; + + @inject(MessageService) + private readonly messageService!: MessageService; + + @inject(StorageService) + private readonly storage!: StorageService; + + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(ConnectAgentCommands.OPEN, { + execute: () => this.openDialog(), + }); + } + + registerMenus(registry: MenuModelRegistry): void { + // Its own group, sorted ahead of Arduino's '0_main' (Getting Started, ...). + registry.registerMenuAction([...CommonMenus.HELP, '0_agent'], { + commandId: ConnectAgentCommands.OPEN.id, + label: ConnectAgentCommands.OPEN.label, + order: '0', + }); + } + + onStart(): void { + this.mcpFrontend.onDidChangeStatus((status) => this.showStatus(status)); + void this.refreshStatus().then((status) => this.maybeShowFirstRunHint(status)); + // Activity expires without any event, so re-read it now and then. + setInterval(() => void this.refreshStatus(), STATUS_POLL_MS); + } + + private async refreshStatus(): Promise { + try { + const status = await this.mcpService.getStatus(); + this.showStatus(status); + return status; + } catch { + return undefined; // backend restarting; the next poll catches up + } + } + + private showStatus(status: MCPStatus): void { + const url = `http://127.0.0.1:${status.port}/mcp`; + let text: string; + let tooltip: string; + if (!status.running && status.error) { + text = '$(warning) MCP unavailable'; + tooltip = status.error; + } else if (!status.running) { + text = '$(debug-disconnect) MCP off'; + tooltip = 'The MCP server is off (Preferences > MCP). Click for connection setup.'; + } else if (status.activeClients > 0) { + const n = status.activeClients; + text = n === 1 ? '$(hubot) Agent connected' : `$(hubot) ${n} agents connected`; + tooltip = `${n} MCP session${n === 1 ? '' : 's'} active in the last 5 minutes on ${url}. Click for connection setup.`; + } else { + text = '$(hubot) MCP ready'; + tooltip = `MCP server listening on ${url}. Click to connect an AI agent.`; + } + this.statusBar.setElement(STATUS_BAR_ID, { + alignment: StatusBarAlignment.LEFT, + priority: 100, + text, + tooltip, + command: ConnectAgentCommands.OPEN.id, + className: 'arduino-agent-mcp-status', + }); + } + + private async maybeShowFirstRunHint(status: MCPStatus | undefined): Promise { + if (!status?.running || (await this.storage.getData(HINT_SHOWN_KEY))) { + return; + } + await this.storage.setData(HINT_SHOWN_KEY, true); + const show = 'Show me how'; + const choice = await this.messageService.info( + 'Arduino Agent is ready for your AI assistant. Connect Claude Code, Claude Desktop or another MCP client in one step.', + show + ); + if (choice === show) { + await this.openDialog(); + } + } + + private async openDialog(): Promise { + const guide = await this.mcpService.getConnectionGuide(); + this.showStatus(guide.status); + await new ConnectAgentDialog(guide, this.clipboard).open(); + } +} + +class ConnectAgentDialog extends AbstractDialog { + constructor( + private readonly guide: MCPConnectionGuide, + private readonly clipboard: ClipboardService + ) { + super({ title: 'Connect an AI Agent', maxWidth: 760, wordWrap: 'break-word' }); + ensureStyles(); + this.contentNode.classList.add('arduino-agent-connect'); + this.contentNode.appendChild(this.renderStatus()); + for (const snippet of guide.snippets) { + this.contentNode.appendChild(this.renderSnippet(snippet)); + } + this.appendAcceptButton('Done'); + } + + get value(): void { + return undefined; + } + + private renderStatus(): HTMLElement { + const { status, url } = this.guide; + const node = element('div', 'arduino-agent-connect-status'); + const dot = element('span', `arduino-agent-connect-dot ${status.running ? 'on' : 'off'}`); + node.appendChild(dot); + node.appendChild( + document.createTextNode( + status.running + ? `MCP server running on ${url}` + + (status.activeClients ? ` - ${status.activeClients} active session(s)` : '') + : status.error ?? + 'The MCP server is off. Turn it on in Preferences > MCP, then come back here.' + ) + ); + return node; + } + + private renderSnippet(snippet: MCPConnectionSnippet): HTMLElement { + const section = element('div', 'arduino-agent-connect-section'); + const header = element('div', 'arduino-agent-connect-header'); + header.appendChild(element('div', 'arduino-agent-connect-title', snippet.title)); + const copy = element('button', 'theia-button secondary arduino-agent-connect-copy', 'Copy') as HTMLButtonElement; + copy.addEventListener('click', async () => { + await this.clipboard.writeText(snippet.text); + copy.textContent = 'Copied'; + setTimeout(() => (copy.textContent = 'Copy'), 1500); + }); + header.appendChild(copy); + section.appendChild(header); + section.appendChild(element('div', 'arduino-agent-connect-description', snippet.description)); + section.appendChild(element('pre', 'arduino-agent-connect-code', snippet.text)); + return section; + } +} + +function element(tag: string, className: string, text?: string): HTMLElement { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) { + node.textContent = text; + } + return node; +} + +let stylesInstalled = false; +function ensureStyles(): void { + if (stylesInstalled) { + return; + } + stylesInstalled = true; + const style = document.createElement('style'); + style.textContent = ` +.arduino-agent-connect { display: flex; flex-direction: column; gap: 14px; min-width: 560px; } +.arduino-agent-connect-status { display: flex; align-items: center; gap: 8px; } +.arduino-agent-connect-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; } +.arduino-agent-connect-dot.on { background: var(--theia-testing-iconPassed, #3fb950); } +.arduino-agent-connect-dot.off { background: var(--theia-errorForeground, #f85149); } +.arduino-agent-connect-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.arduino-agent-connect-title { font-weight: 600; } +.arduino-agent-connect-copy { min-width: 72px; margin: 0; } +.arduino-agent-connect-description { opacity: 0.8; margin: 4px 0 6px; } +.arduino-agent-connect-code { + margin: 0; padding: 8px 10px; border-radius: 4px; max-height: 180px; overflow: auto; + white-space: pre-wrap; word-break: break-all; user-select: text; + font-family: var(--theia-code-font-family, monospace); font-size: var(--theia-code-font-size, 12px); + background: var(--theia-textCodeBlock-background, rgba(127, 127, 127, 0.12)); + border: 1px solid var(--theia-editorWidget-border, rgba(127, 127, 127, 0.3)); +} +`; + document.head.appendChild(style); +} diff --git a/arduino-mcp-extension/src/browser/mcp-frontend-contribution.ts b/arduino-mcp-extension/src/browser/mcp-frontend-contribution.ts index 48a2024b8b7..3f37e47d51d 100644 --- a/arduino-mcp-extension/src/browser/mcp-frontend-contribution.ts +++ b/arduino-mcp-extension/src/browser/mcp-frontend-contribution.ts @@ -13,6 +13,7 @@ import { FrontendApplicationContribution } from '@theia/core/lib/browser/fronten import { FrontendApplication } from '@theia/core/lib/browser/frontend-application'; import { PreferenceService } from '@theia/core/lib/browser/preferences'; import { MessageService } from '@theia/core/lib/common/message-service'; +import { Emitter, Event } from '@theia/core/lib/common/event'; import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; import URI from '@theia/core/lib/common/uri'; import { @@ -54,6 +55,10 @@ export class MCPFrontendContribution private recentFileChanges = new Map(); private readonly NOTIFICATION_DEBOUNCE_MS = 2000; + private readonly onDidChangeStatusEmitter = new Emitter(); + /** Server status pushed by the backend (start/stop, sessions opening or closing). */ + readonly onDidChangeStatus: Event = this.onDidChangeStatusEmitter.event; + @postConstruct() protected init(): void { // Listen for preference changes @@ -198,6 +203,7 @@ export class MCPFrontendContribution */ onStatusChanged(status: MCPStatus): void { console.log('[arduino-mcp] MCP status changed:', status); + this.onDidChangeStatusEmitter.fire(status); } // ============================================================ diff --git a/arduino-mcp-extension/src/common/mcp-service.ts b/arduino-mcp-extension/src/common/mcp-service.ts index ecc1f67606a..3b5201117cd 100644 --- a/arduino-mcp-extension/src/common/mcp-service.ts +++ b/arduino-mcp-extension/src/common/mcp-service.ts @@ -18,9 +18,39 @@ export interface MCPStatus { enabled: boolean; running: boolean; port: number; + /** Open sessions. Streamable HTTP clients rarely close theirs, so this only grows. */ connectedClients: number; + /** Sessions that made a request within the last ACTIVE_CLIENT_WINDOW_MS. */ + activeClients: number; uptime: number; // seconds authRequired: boolean; + /** Why the server is not running although it should be (e.g. its port is taken). */ + error?: string; +} + +/** How recently a session must have made a request to count as active. */ +export const ACTIVE_CLIENT_WINDOW_MS = 5 * 60 * 1000; + +/** One ready-to-paste way of connecting a client. */ +export interface MCPConnectionSnippet { + id: 'claude-code' | 'bridge-config' | 'http-config'; + title: string; + description: string; + text: string; +} + +/** + * Everything needed to connect an MCP client to this installation: the + * snippets use the real paths of the bundled bridge and of the app itself. + */ +export interface MCPConnectionGuide { + status: MCPStatus; + url: string; + /** The bundled stdio bridge, or null if this build has none. */ + bridgePath: string | null; + /** The app executable to use as ARDUINO_AGENT_PATH, or null in dev builds. */ + appExecutable: string | null; + snippets: MCPConnectionSnippet[]; } /** @@ -104,6 +134,12 @@ export interface MCPService { */ getClientConfig(): Promise; + /** + * Connection setups for Claude Code, config-file clients (via the bundled + * bridge) and direct HTTP, with this installation's paths filled in. + */ + getConnectionGuide(): Promise; + /** * Check if MCP server is healthy and accepting connections */ diff --git a/arduino-mcp-extension/src/node/mcp-server.ts b/arduino-mcp-extension/src/node/mcp-server.ts index bd5d444496a..7ea00e3e48a 100644 --- a/arduino-mcp-extension/src/node/mcp-server.ts +++ b/arduino-mcp-extension/src/node/mcp-server.ts @@ -50,6 +50,7 @@ import { MCP_SERVER_INSTRUCTIONS } from '../common/mcp-instructions'; import { MCP_PROMPTS, findPrompt } from '../common/mcp-prompts'; import { Task } from '../common/mcp-types'; import { + ACTIVE_CLIENT_WINDOW_MS, MCPStatus, MCPFileChangeEvent, MCPIDEState, @@ -391,6 +392,12 @@ export class ArduinoMCPServer { string, StreamableHTTPServerTransport >(); + // Last request time per session, for MCPStatus.activeClients + private readonly sessionLastSeen = new Map(); + // Notified when a session opens or closes (the IDE's status bar) + private sessionsChangedListener: (() => void) | null = null; + // Why the last start() failed, shown in the IDE until a start succeeds + private startError: string | null = null; // Task management for async operations private readonly tasks = new Map(); @@ -446,10 +453,14 @@ export class ArduinoMCPServer { port: this.port, connectedClients: this.sseTransports.size + this.streamableTransports.size, + activeClients: [...this.sessionLastSeen.values()].filter( + (seen) => Date.now() - seen < ACTIVE_CLIENT_WINDOW_MS + ).length, uptime: this.isRunning ? Math.floor((Date.now() - this.startTime) / 1000) : 0, authRequired: this.requireAuth, + error: this.isRunning ? undefined : this.startError ?? undefined, }; } @@ -480,12 +491,18 @@ export class ArduinoMCPServer { mcpLog.error( `Port ${this.port} is in use. Change the arduino.mcp.port preference or set the ARDUINO_MCP_PORT env var.` ); + this.startError = + `Port ${this.port} is already in use, probably by another running copy of Arduino Agent. ` + + 'Close it, or choose another port in Preferences > MCP.'; + } else { + this.startError = `The MCP server could not start: ${err.message}`; } reject(err); }); this.httpServer!.listen(this.port, '127.0.0.1', () => { this.isRunning = true; + this.startError = null; this.startTime = Date.now(); mcpLog.info( `MCP Server listening on http://127.0.0.1:${this.port} (auth: ${ @@ -507,6 +524,7 @@ export class ArduinoMCPServer { await transport.close().catch(() => undefined); } this.streamableTransports.clear(); + this.sessionLastSeen.clear(); await this.serialManager?.disconnect().catch(() => undefined); return new Promise((resolve) => { @@ -558,6 +576,27 @@ export class ArduinoMCPServer { this.fileChangeCallback = callback; } + setSessionsChangedListener(listener: (() => void) | null): void { + this.sessionsChangedListener = listener; + } + + private touchSession(sessionId: string | undefined): void { + if (sessionId) { + this.sessionLastSeen.set(sessionId, Date.now()); + } + } + + private sessionOpened(sessionId: string): void { + this.touchSession(sessionId); + this.sessionsChangedListener?.(); + } + + private sessionClosed(sessionId: string | undefined): void { + if (sessionId && this.sessionLastSeen.delete(sessionId)) { + this.sessionsChangedListener?.(); + } + } + /** * A ready-to-paste MCP client configuration snippet. */ @@ -727,11 +766,13 @@ export class ArduinoMCPServer { onsessioninitialized: (sid: string) => { this.streamableTransports.set(sid, newTransport); mcpLog.info(`Streamable HTTP session initialized: ${sid}`); + this.sessionOpened(sid); }, }); newTransport.onclose = () => { if (newTransport.sessionId) { this.streamableTransports.delete(newTransport.sessionId); + this.sessionClosed(newTransport.sessionId); } }; const server = this.createMCPServerInstance(); @@ -739,6 +780,7 @@ export class ArduinoMCPServer { transport = newTransport; } + this.touchSession(sessionId); await transport.handleRequest(req, res); } @@ -751,10 +793,12 @@ export class ArduinoMCPServer { await server.connect(transport); this.sseTransports.set(transport.sessionId, transport); mcpLog.info(`SSE session established: ${transport.sessionId}`); + this.sessionOpened(transport.sessionId); req.on('close', () => { mcpLog.info(`SSE session closed: ${transport.sessionId}`); this.sseTransports.delete(transport.sessionId); + this.sessionClosed(transport.sessionId); }); } @@ -774,6 +818,7 @@ export class ArduinoMCPServer { res.end(JSON.stringify({ error: 'No active SSE session for this id' })); return; } + this.touchSession(transport.sessionId); const chunks: Buffer[] = []; req.on('data', (chunk) => chunks.push(chunk)); diff --git a/arduino-mcp-extension/src/node/mcp-service-impl.ts b/arduino-mcp-extension/src/node/mcp-service-impl.ts index b1e3e6b6f10..aa346ac4ebf 100644 --- a/arduino-mcp-extension/src/node/mcp-service-impl.ts +++ b/arduino-mcp-extension/src/node/mcp-service-impl.ts @@ -7,6 +7,8 @@ * sketch, board/port selection) pushed from the frontend. */ +import * as fs from 'fs'; +import * as path from 'path'; import { injectable, inject } from '@theia/core/shared/inversify'; import { MCPService, @@ -14,11 +16,41 @@ import { MCPServiceClient, MCPFileChangeEvent, MCPIDEState, + MCPConnectionGuide, + MCPConnectionSnippet, ToolMode, } from '../common/mcp-service'; import { ArduinoMCPServer } from './mcp-server'; import { mcpLog } from './mcp-logger'; +const BRIDGE_IN_RESOURCES = path.join('mcp-bridge', 'bridge', 'arduino-agent-bridge.js'); + +/** + * The stdio bridge shipped with this build: `resources/mcp-bridge/` in a + * release (see electron-app's extraResources), or the extension's own + * `bridge/` in a source checkout. + */ +function findBridge(): string | null { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + resourcesPath && path.join(resourcesPath, BRIDGE_IN_RESOURCES), + // Bundled backend: resources/app/lib/backend -> resources + path.resolve(__dirname, '..', '..', '..', BRIDGE_IN_RESOURCES), + // Unbundled: arduino-mcp-extension/lib/node -> arduino-mcp-extension/bridge + path.resolve(__dirname, '..', '..', 'bridge', 'arduino-agent-bridge.js'), + // Bundled in a checkout: electron-app/lib/backend -> repo root + path.resolve(__dirname, '..', '..', '..', 'arduino-mcp-extension', 'bridge', 'arduino-agent-bridge.js'), + ]; + return candidates.find((p): p is string => !!p && fs.existsSync(p)) ?? null; +} + +/** The app executable (for ARDUINO_AGENT_PATH), or null when running from a dev Electron. */ +function findAppExecutable(): string | null { + return /^arduino[ -](agent|ide)(\.exe)?$/i.test(path.basename(process.execPath)) + ? process.execPath + : null; +} + @injectable() export class MCPServiceImpl implements MCPService { @inject(ArduinoMCPServer) @@ -79,6 +111,58 @@ export class MCPServiceImpl implements MCPService { return this.mcpServer.buildClientConfig(); } + async getConnectionGuide(): Promise { + const url = await this.getServerUrl(); + const token = this.mcpServer.getAuthToken(); + const bridgePath = findBridge(); + const appExecutable = findAppExecutable(); + const quote = (s: string) => `"${s}"`; + const snippets: MCPConnectionSnippet[] = []; + + if (bridgePath) { + const env = appExecutable ? ` --env ARDUINO_AGENT_PATH=${quote(appExecutable)}` : ''; + snippets.push({ + id: 'claude-code', + title: 'Claude Code', + description: + 'Run this in a terminal. It adds Arduino Agent to Claude Code for all your projects, ' + + 'through the bundled bridge (needs Node.js 18 or newer).', + text: `claude mcp add arduino --scope user${env} -- node ${quote(bridgePath)}`, + }); + const server: Record = { command: 'node', args: [bridgePath] }; + if (appExecutable) { + server.env = { ARDUINO_AGENT_PATH: appExecutable }; + } + snippets.push({ + id: 'bridge-config', + title: 'Claude Desktop, Cursor and other MCP clients', + description: + 'Add this to the client\'s MCP configuration (claude_desktop_config.json, .mcp.json, ...). ' + + 'The bundled bridge stays connected while Arduino Agent is closed and starts it when a ' + + 'tool needs it. Needs Node.js 18 or newer.', + text: JSON.stringify({ mcpServers: { arduino: server } }, null, 2), + }); + } else { + const header = token ? ` --header ${quote(`Authorization: Bearer ${token}`)}` : ''; + snippets.push({ + id: 'claude-code', + title: 'Claude Code', + description: 'Run this in a terminal. It connects while Arduino Agent is running.', + text: `claude mcp add arduino --scope user --transport http ${url}${header}`, + }); + } + snippets.push({ + id: 'http-config', + title: 'Direct HTTP', + description: + 'For clients that speak Streamable HTTP. Only connects while Arduino Agent is running' + + (token ? '; the token is stored in ~/.arduinoIDE/mcp-token.' : '.'), + text: this.mcpServer.buildClientConfig(), + }); + + return { status: this.mcpServer.getStatus(), url, bridgePath, appExecutable, snippets }; + } + async healthCheck(): Promise { return this.mcpServer.isServerRunning(); } @@ -97,6 +181,7 @@ export class MCPServiceImpl implements MCPService { this.mcpServer.setFileChangeCallback((event) => this.notifyFileChanged(event) ); + this.mcpServer.setSessionsChangedListener(() => this.notifyStatusChanged()); } /**