Skip to content

Commit 6c4b609

Browse files
committed
adding tui ui for mcp
1 parent 6d8645d commit 6c4b609

8 files changed

Lines changed: 348 additions & 30 deletions

File tree

src/commands/mcp.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ async function handleAdd(
373373
if (!targetConfig.mcp.servers) {
374374
targetConfig.mcp.servers = [];
375375
}
376+
const wasMcpDisabled = targetConfig.mcp.enabled === false;
377+
if (wasMcpDisabled) {
378+
targetConfig.mcp.enabled = true;
379+
}
376380

377381
const newServer = transport === 'stdio'
378382
? {
@@ -405,7 +409,31 @@ async function handleAdd(
405409
&& existing.url === target;
406410

407411
if (sameConfig) {
408-
return `Server "${name}" is already configured with the same settings${scopeSuffix}.`;
412+
const wasAutoConnectDisabled = existing.autoConnect === false;
413+
if (!wasAutoConnectDisabled && !wasMcpDisabled) {
414+
return `Server "${name}" is already configured with the same settings${scopeSuffix}.`;
415+
}
416+
417+
existing.autoConnect = true;
418+
const reenabledParts: string[] = [];
419+
if (wasMcpDisabled) reenabledParts.push('MCP support');
420+
if (wasAutoConnectDisabled) reenabledParts.push('auto-connect');
421+
const reenabled = reenabledParts.join(' and ');
422+
423+
try {
424+
await saveConfig(targetConfig);
425+
syncRuntimeConfig(config, targetConfig);
426+
427+
try {
428+
await manager.connect(existing);
429+
const tools = manager.getToolsForServer(name);
430+
return `Server "${name}" is already configured${scopeSuffix}. Re-enabled ${reenabled} and connected (${tools.length} tools available).`;
431+
} catch (connectError) {
432+
return `Server "${name}" is already configured${scopeSuffix}. Re-enabled ${reenabled} but failed to connect: ${connectError instanceof Error ? connectError.message : 'Unknown error'}`;
433+
}
434+
} catch (error) {
435+
return `Failed to save config: ${error instanceof Error ? error.message : 'Unknown error'}`;
436+
}
409437
}
410438

411439
// Update in-place

src/core/agent.ts

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ import { WorkspaceFileCollector } from './agent/WorkspaceFileCollector.js';
9797
import { ProviderConfigManager } from './agent/ProviderConfigManager.js';
9898
import { AutoReportManager } from '../reporting/AutoReportManager.js';
9999
import { isLikelyFilePathSlashInput } from './slashInputDetection.js';
100+
import {
101+
buildMcpStartupSummaryRows,
102+
getAutoConnectMcpServerNames,
103+
truncateMcpStartupError,
104+
} from './mcpStartupHistory.js';
100105

101106
export class AutohandAgent {
102107
private mentionContexts: { path: string; contents: string }[] = [];
@@ -646,6 +651,9 @@ export class AutohandAgent {
646651
/** Promise that resolves when background init is complete */
647652
private initReady: Promise<void> | null = null;
648653
private initDone = false;
654+
private mcpStartupAutoConnectServers: string[] = [];
655+
private mcpStartupConnectStartedAt: number | null = null;
656+
private mcpStartupSummaryPrinted = false;
649657

650658
async runInteractive(): Promise<void> {
651659
// Bail out early if stdin is not a TTY - interactive mode requires a terminal
@@ -655,6 +663,16 @@ export class AutohandAgent {
655663
return;
656664
}
657665

666+
// Prepare startup visibility for async MCP connections.
667+
this.mcpStartupAutoConnectServers = getAutoConnectMcpServerNames(this.runtime.config.mcp?.servers);
668+
this.mcpStartupConnectStartedAt = null;
669+
this.mcpStartupSummaryPrinted = false;
670+
if (this.runtime.config.mcp?.enabled !== false && this.mcpStartupAutoConnectServers.length > 0) {
671+
const count = this.mcpStartupAutoConnectServers.length;
672+
const label = count === 1 ? 'server' : 'servers';
673+
console.log(chalk.gray(`MCP startup: connecting ${count} ${label} in background...`));
674+
}
675+
658676
// Start ALL initialization in background so prompt appears instantly.
659677
// The user can start typing while managers initialize.
660678
// When they submit, we await initReady before processing.
@@ -685,6 +703,7 @@ export class AutohandAgent {
685703
// Servers connect asynchronously; tools become available once ready.
686704
// Does NOT block the main init pipeline or user prompt.
687705
if (this.runtime.config.mcp?.enabled !== false) {
706+
this.mcpStartupConnectStartedAt = Date.now();
688707
this.mcpReady = this.mcpManager
689708
.connectAll(this.runtime.config.mcp?.servers ?? [])
690709
.then(() => { this.syncMcpTools(); })
@@ -732,6 +751,7 @@ export class AutohandAgent {
732751
if (this.mcpReady) {
733752
await this.mcpReady;
734753
}
754+
this.printMcpStartupSummaryIfNeeded();
735755

736756
// Fire session-start hook now that the prompt is closed and stdout is clean
737757
const session = this.sessionManager.getCurrentSession();
@@ -1213,33 +1233,32 @@ If lint or tests fail, report the issues but do NOT commit.`;
12131233
return null;
12141234
}
12151235

1216-
if (normalized.startsWith('/') && !isLikelyFilePathSlashInput(normalized)) {
1217-
// Parse command and arguments from input
1218-
const parts = normalized.split(/\s+/);
1219-
let command = parts[0];
1220-
let args = parts.slice(1);
1221-
1222-
// Handle multi-word commands like "/skills install", "/mcp install"
1223-
const twoWordCommands = ['/skills install', '/skills new', '/skills use', '/agents new', '/mcp install'];
1224-
const potentialTwoWord = `${parts[0]} ${parts[1] || ''}`.trim();
1225-
if (twoWordCommands.includes(potentialTwoWord)) {
1226-
command = potentialTwoWord;
1227-
args = parts.slice(2);
1228-
}
1229-
1230-
// /quit and /exit return themselves as pass-through instructions
1231-
// so the interactive loop's special exit handler (line 963) can catch them.
1232-
// Skip the slash handler for these — they're control-flow, not commands.
1233-
if (command === '/quit' || command === '/exit') {
1234-
return command;
1235-
}
1236+
if (normalized.startsWith('/')) {
1237+
// Always prioritize known slash commands, even when args contain '/'
1238+
// (e.g. package specs like "@playwright/mcp@latest").
1239+
const parsed = this.parseSlashCommand(normalized);
1240+
const isKnownSlashCommand = this.isSlashCommandSupported(parsed.command);
1241+
if (!isKnownSlashCommand && isLikelyFilePathSlashInput(normalized)) {
1242+
// Looks like an absolute file path, not a command.
1243+
// Fall through to normal prompt handling below.
1244+
} else {
1245+
const command = parsed.command;
1246+
const args = parsed.args;
1247+
1248+
// /quit and /exit return themselves as pass-through instructions
1249+
// so the interactive loop's special exit handler (line 963) can catch them.
1250+
// Skip the slash handler for these — they're control-flow, not commands.
1251+
if (command === '/quit' || command === '/exit') {
1252+
return command;
1253+
}
12361254

1237-
const handled = await this.handleSlashCommand(command, args);
1238-
if (handled !== null) {
1239-
// Slash command returned display output — print it, don't send to LLM
1240-
console.log(handled);
1255+
const handled = await this.handleSlashCommand(command, args);
1256+
if (handled !== null) {
1257+
// Slash command returned display output — print it, don't send to LLM
1258+
console.log(handled);
1259+
}
1260+
return null;
12411261
}
1242-
return null;
12431262
}
12441263

12451264
// Handle # trigger for storing memories
@@ -4013,6 +4032,12 @@ If lint or tests fail, report the issues but do NOT commit.`;
40134032
* Returns the command output or null if the command doesn't exist
40144033
*/
40154034
async handleSlashCommand(command: string, args: string[] = []): Promise<string | null> {
4035+
// /mcp depends on background startup state (notably MCP auto-connect).
4036+
// Ensure startup init is settled before rendering server status/actions.
4037+
if (command === '/mcp' || command === '/mcp install') {
4038+
await this.ensureInitComplete();
4039+
}
4040+
40164041
const result = await this.slashHandler.handle(command, args);
40174042
if (command === '/mcp' || command === '/mcp install') {
40184043
this.syncMcpTools();
@@ -4264,6 +4289,64 @@ If lint or tests fail, report the issues but do NOT commit.`;
42644289
return `${planIndicator}${percent}% context left · ${t('ui.commandHint')}${queueStatus}`;
42654290
}
42664291

4292+
private printMcpStartupSummaryIfNeeded(): void {
4293+
if (this.mcpStartupSummaryPrinted) {
4294+
return;
4295+
}
4296+
if (this.runtime.config.mcp?.enabled === false) {
4297+
this.mcpStartupSummaryPrinted = true;
4298+
return;
4299+
}
4300+
if (this.mcpStartupAutoConnectServers.length === 0) {
4301+
this.mcpStartupSummaryPrinted = true;
4302+
return;
4303+
}
4304+
4305+
this.mcpStartupSummaryPrinted = true;
4306+
4307+
const rows = buildMcpStartupSummaryRows(
4308+
this.mcpStartupAutoConnectServers,
4309+
this.mcpManager.listServers()
4310+
);
4311+
4312+
const elapsed = this.mcpStartupConnectStartedAt
4313+
? formatElapsedTime(this.mcpStartupConnectStartedAt)
4314+
: null;
4315+
4316+
const connected = rows.filter((row) => row.status === 'connected').length;
4317+
const failed = rows.filter((row) => row.status === 'error').length;
4318+
const disconnected = rows.filter((row) => row.status === 'disconnected').length;
4319+
const summaryParts = [
4320+
`${connected} connected`,
4321+
failed > 0 ? `${failed} failed` : null,
4322+
disconnected > 0 ? `${disconnected} disconnected` : null,
4323+
].filter(Boolean).join(', ');
4324+
const elapsedSuffix = elapsed ? ` in ${elapsed}` : '';
4325+
4326+
console.log(chalk.bold('\n* MCP startup'));
4327+
console.log(chalk.gray(` Async connection phase complete${elapsedSuffix} (${summaryParts})`));
4328+
4329+
for (const row of rows) {
4330+
if (row.status === 'connected') {
4331+
const toolLabel = row.toolCount === 1 ? 'tool' : 'tools';
4332+
console.log(` ${chalk.green('✓')} ${row.name} connected (${row.toolCount} ${toolLabel})`);
4333+
continue;
4334+
}
4335+
4336+
if (row.status === 'error') {
4337+
const errorSuffix = row.error
4338+
? `: ${truncateMcpStartupError(row.error)}`
4339+
: '';
4340+
console.log(` ${chalk.red('✖')} ${row.name} failed${errorSuffix}`);
4341+
continue;
4342+
}
4343+
4344+
console.log(` ${chalk.yellow('○')} ${row.name} not connected`);
4345+
}
4346+
4347+
console.log();
4348+
}
4349+
42674350
private async resetConversationContext(): Promise<void> {
42684351
const systemPrompt = await this.buildSystemPrompt();
42694352
this.conversation.reset(systemPrompt);

src/core/mcpStartupHistory.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Autohand AI LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
export interface McpStartupConfiguredServer {
8+
name: string;
9+
autoConnect?: boolean;
10+
}
11+
12+
export interface McpStartupRuntimeServer {
13+
name: string;
14+
status: 'connected' | 'disconnected' | 'error';
15+
toolCount: number;
16+
error?: string;
17+
}
18+
19+
export interface McpStartupSummaryRow {
20+
name: string;
21+
status: 'connected' | 'disconnected' | 'error';
22+
toolCount: number;
23+
error?: string;
24+
}
25+
26+
/**
27+
* Return configured MCP server names that should auto-connect on startup.
28+
*/
29+
export function getAutoConnectMcpServerNames(
30+
servers: McpStartupConfiguredServer[] | undefined
31+
): string[] {
32+
if (!servers || servers.length === 0) {
33+
return [];
34+
}
35+
36+
return servers
37+
.filter((server) => server.autoConnect !== false)
38+
.map((server) => server.name);
39+
}
40+
41+
/**
42+
* Build a stable startup summary in configured order.
43+
*/
44+
export function buildMcpStartupSummaryRows(
45+
autoConnectServerNames: string[],
46+
runtimeServers: McpStartupRuntimeServer[]
47+
): McpStartupSummaryRow[] {
48+
const runtimeMap = new Map(runtimeServers.map((server) => [server.name, server]));
49+
50+
return autoConnectServerNames.map((name) => {
51+
const runtime = runtimeMap.get(name);
52+
if (!runtime) {
53+
return {
54+
name,
55+
status: 'disconnected',
56+
toolCount: 0,
57+
};
58+
}
59+
60+
return {
61+
name,
62+
status: runtime.status,
63+
toolCount: runtime.toolCount,
64+
error: runtime.error,
65+
};
66+
});
67+
}
68+
69+
export function truncateMcpStartupError(error: string, maxLength = 140): string {
70+
if (error.length <= maxLength) {
71+
return error;
72+
}
73+
return `${error.slice(0, maxLength - 1)}…`;
74+
}
75+

src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,10 @@ mcpCmd
388388

389389
if (!config.mcp) config.mcp = {};
390390
if (!config.mcp.servers) config.mcp.servers = [];
391+
const wasMcpDisabled = config.mcp.enabled === false;
392+
if (wasMcpDisabled) {
393+
config.mcp.enabled = true;
394+
}
391395

392396
const transport = (options.transport ?? 'stdio').toLowerCase();
393397
if (transport !== 'stdio' && transport !== 'http' && transport !== 'sse') {
@@ -444,6 +448,21 @@ mcpCmd
444448
&& existing.url === target;
445449

446450
if (sameConfig) {
451+
const wasAutoConnectDisabled = existing.autoConnect === false;
452+
if (wasAutoConnectDisabled || wasMcpDisabled) {
453+
existing.autoConnect = true;
454+
await saveConfig(config);
455+
456+
const reenabledParts: string[] = [];
457+
if (wasMcpDisabled) reenabledParts.push('MCP support');
458+
if (wasAutoConnectDisabled) reenabledParts.push('auto-connect');
459+
const reenabled = reenabledParts.join(' and ');
460+
461+
console.log(chalk.green(`Server "${name}" is already configured. Re-enabled ${reenabled}.`));
462+
console.log(chalk.gray('Server will auto-connect when you start autohand.'));
463+
process.exit(0);
464+
}
465+
447466
console.log(chalk.green(`Server "${name}" is already configured with the same settings.`));
448467
process.exit(0);
449468
}

src/mcp/McpClientManager.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -999,12 +999,15 @@ export class McpClientManager {
999999
connection: McpStdioConnection,
10001000
tools: McpToolDefinition[]
10011001
): void {
1002-
connection.on('close', () => {
1002+
connection.on('close', (code: number | null | undefined) => {
10031003
const state = this.servers.get(config.name);
1004-
// Only set to 'disconnected' if currently connected.
1005-
// Preserve 'error' status so the user can see why connection failed.
1004+
// Only mutate state if currently connected.
1005+
// Preserve existing error state from handshake failures.
10061006
if (state && state.status === 'connected') {
1007-
state.status = 'disconnected';
1007+
state.status = 'error';
1008+
state.error = typeof code === 'number'
1009+
? `MCP server process exited with code ${code}`
1010+
: 'MCP server disconnected unexpectedly';
10081011
}
10091012
});
10101013

0 commit comments

Comments
 (0)