Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ export class SettingsComponent extends React.Component<
<p style={{ marginTop: 0, marginBottom: '16px', opacity: 0.8, fontSize: '12px' }}>
{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.'
)}
</p>
</div>
Expand Down Expand Up @@ -618,12 +618,16 @@ export class SettingsComponent extends React.Component<
</div>

<div style={{ marginTop: '16px', padding: '12px', background: 'var(--theia-editor-background)', borderRadius: '4px' }}>
{/* Arduino Agent: the streamable HTTP endpoint (SSE is legacy) and the setup dialog */}
<div style={{ fontSize: '12px', opacity: 0.8 }}>
{nls.localize('arduino/preferences/mcp.connectionInfo', 'To connect Claude Code, add to your MCP configuration:')}
{nls.localize('arduino/preferences/mcp.serverAddress', 'MCP server address:')}
</div>
<code style={{ display: 'block', marginTop: '8px', fontSize: '11px', wordBreak: 'break-all' }}>
http://127.0.0.1:{this.state.mcpPort}/sse
http://127.0.0.1:{this.state.mcpPort}/mcp
</code>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '8px' }}>
{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...')}
</div>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
});
235 changes: 235 additions & 0 deletions arduino-mcp-extension/src/browser/connect-agent.ts
Original file line number Diff line number Diff line change
@@ -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<MCPStatus | undefined> {
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<void> {
if (!status?.running || (await this.storage.getData<boolean>(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<void> {
const guide = await this.mcpService.getConnectionGuide();
this.showStatus(guide.status);
await new ConnectAgentDialog(guide, this.clipboard).open();
}
}

class ConnectAgentDialog extends AbstractDialog<void> {
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -54,6 +55,10 @@ export class MCPFrontendContribution
private recentFileChanges = new Map<string, number>();
private readonly NOTIFICATION_DEBOUNCE_MS = 2000;

private readonly onDidChangeStatusEmitter = new Emitter<MCPStatus>();
/** Server status pushed by the backend (start/stop, sessions opening or closing). */
readonly onDidChangeStatus: Event<MCPStatus> = this.onDidChangeStatusEmitter.event;

@postConstruct()
protected init(): void {
// Listen for preference changes
Expand Down Expand Up @@ -198,6 +203,7 @@ export class MCPFrontendContribution
*/
onStatusChanged(status: MCPStatus): void {
console.log('[arduino-mcp] MCP status changed:', status);
this.onDidChangeStatusEmitter.fire(status);
}

// ============================================================
Expand Down
36 changes: 36 additions & 0 deletions arduino-mcp-extension/src/common/mcp-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -104,6 +134,12 @@ export interface MCPService {
*/
getClientConfig(): Promise<string>;

/**
* Connection setups for Claude Code, config-file clients (via the bundled
* bridge) and direct HTTP, with this installation's paths filled in.
*/
getConnectionGuide(): Promise<MCPConnectionGuide>;

/**
* Check if MCP server is healthy and accepting connections
*/
Expand Down
Loading
Loading