interactive-mcp-enhanced
Version:
Enhanced MCP Server for interactive communication between LLMs and users with custom sound notifications, approval workflows, clipboard support, and improved user experience.
292 lines (291 loc) • 11.9 kB
JavaScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import notifier from 'node-notifier';
import yargs from 'yargs';
import { playNotificationSound } from './utils/sound-manager.js';
import { hideBin } from 'yargs/helpers';
import { getCmdWindowInput } from './commands/input/index.js';
import { startIntensiveChatSession, askQuestionInSession, stopIntensiveChatSession, } from './commands/intensive-chat/index.js';
import { USER_INPUT_TIMEOUT_SECONDS } from './constants.js';
// Import tool definitions using the new structure
import { requestUserInputTool } from './tool-definitions/request-user-input.js';
import { intensiveChatTools } from './tool-definitions/intensive-chat.js';
// --- End Define Type ---
// --- Define Full Tool Capabilities from Imports --- (Simplified construction)
const allToolCapabilities = {
request_user_input: requestUserInputTool.capability,
start_intensive_chat: intensiveChatTools.start.capability,
ask_intensive_chat: intensiveChatTools.ask.capability,
stop_intensive_chat: intensiveChatTools.stop.capability,
};
// --- End Define Full Tool Capabilities from Imports ---
// Parse command-line arguments for global timeout
const argv = yargs(hideBin(process.argv))
.option('timeout', {
alias: 't',
type: 'number',
description: 'Default timeout for user input prompts in seconds',
default: USER_INPUT_TIMEOUT_SECONDS,
})
.option('disable-tools', {
alias: 'd',
type: 'string',
description: 'Comma-separated list of tool names to disable. Available options: request_user_input, intensive_chat (disables all intensive chat tools).',
default: '',
})
.help()
.alias('help', 'h')
.parseSync();
const globalTimeoutSeconds = argv.timeout;
const disabledTools = argv['disable-tools']
.split(',')
.map((tool) => tool.trim())
.filter(Boolean);
// Store active intensive chat sessions
const activeChatSessions = new Map();
/**
* Play notification sound and bring window to front for interactive tools
*/
async function playNotificationAndActivate() {
// Play custom notification sound
await playNotificationSound();
// Also show a brief system notification (without sound since we have custom sound)
notifier.notify({
title: 'Interactive MCP',
message: 'Tool activated',
sound: false, // We're using our custom sound instead
});
}
// --- Filter Capabilities Based on Args ---
// Helper function to check if a tool is effectively disabled (directly or via group)
const isToolDisabled = (toolName) => {
if (disabledTools.includes(toolName)) {
return true;
}
if ([
// Check if tool belongs to the intensive_chat group and the group is disabled
'start_intensive_chat',
'ask_intensive_chat',
'stop_intensive_chat',
].includes(toolName) &&
disabledTools.includes('intensive_chat')) {
return true;
}
return false;
};
// Create a new object with only the enabled tool capabilities
const enabledToolCapabilities = Object.fromEntries(Object.entries(allToolCapabilities).filter(([toolName]) => {
return !isToolDisabled(toolName);
})); // Assert type after filtering
// --- End Filter Capabilities Based on Args ---
// Helper function to check if a tool should be registered (used later)
const isToolEnabled = (toolName) => {
// A tool is enabled if it's present in the filtered capabilities
return toolName in enabledToolCapabilities;
};
// Initialize MCP server with FILTERED capabilities
const server = new McpServer({
name: 'Interactive MCP',
version: '1.0.0',
capabilities: {
tools: enabledToolCapabilities, // Use the filtered capabilities
},
});
// Conditionally register tools based on command-line arguments
if (isToolEnabled('request_user_input')) {
// Use properties from the imported tool object
server.tool('request_user_input',
// Need to handle description potentially being a function
typeof requestUserInputTool.description === 'function'
? requestUserInputTool.description(globalTimeoutSeconds)
: requestUserInputTool.description, requestUserInputTool.schema, // Use schema property
async (args) => {
// Use inferred args type
const { projectName, message, predefinedOptions } = args;
// Play notification sound when tool is called
await playNotificationAndActivate();
const promptMessage = `${projectName}: ${message}`;
const answer = await getCmdWindowInput(projectName, promptMessage, globalTimeoutSeconds, true, predefinedOptions);
// Check for the specific timeout indicator
if (answer === '__TIMEOUT__') {
return {
content: [
{ type: 'text', text: 'User did not reply: Timeout occurred.' },
],
};
}
// Empty string means user submitted empty input, non-empty is actual reply
else if (answer === '') {
return {
content: [{ type: 'text', text: 'User replied with empty input.' }],
};
}
else {
const reply = `User replied: ${answer}`;
return { content: [{ type: 'text', text: reply }] };
}
});
}
// --- Intensive Chat Tool Registrations ---
// Each tool must be checked individually based on filtered capabilities
if (isToolEnabled('start_intensive_chat')) {
// Use properties from the imported intensiveChatTools object
server.tool('start_intensive_chat',
// Description is a function here
typeof intensiveChatTools.start.description === 'function'
? intensiveChatTools.start.description(globalTimeoutSeconds)
: intensiveChatTools.start.description, intensiveChatTools.start.schema, // Use schema property
async (args) => {
// Use inferred args type
const { sessionTitle } = args;
// No sound for starting intensive chat - only for asking questions
try {
// Start a new intensive chat session, passing global timeout
const sessionId = await startIntensiveChatSession(sessionTitle, globalTimeoutSeconds);
// Track this session for the client
activeChatSessions.set(sessionId, sessionTitle);
return {
content: [
{
type: 'text',
text: `Intensive chat session started successfully. Session ID: ${sessionId}`,
},
],
};
}
catch (error) {
let errorMessage = 'Failed to start intensive chat session.';
if (error instanceof Error) {
errorMessage = `Failed to start intensive chat session: ${error.message}`;
}
else if (typeof error === 'string') {
errorMessage = `Failed to start intensive chat session: ${error}`;
}
return {
content: [
{
type: 'text',
text: errorMessage,
},
],
};
}
});
}
if (isToolEnabled('ask_intensive_chat')) {
// Use properties from the imported intensiveChatTools object
server.tool('ask_intensive_chat',
// Description is a string here
typeof intensiveChatTools.ask.description === 'function'
? intensiveChatTools.ask.description(globalTimeoutSeconds) // Should not happen, but safe
: intensiveChatTools.ask.description, intensiveChatTools.ask.schema, // Use schema property
async (args) => {
// Use inferred args type
const { sessionId, question, predefinedOptions } = args;
// Check if session exists
if (!activeChatSessions.has(sessionId)) {
return {
content: [
{ type: 'text', text: 'Error: Invalid or expired session ID.' },
],
};
}
// Play notification sound when asking a question
await playNotificationAndActivate();
try {
// Ask the question in the session
const answer = await askQuestionInSession(sessionId, question, predefinedOptions);
// Check for the specific timeout indicator
if (answer === '__TIMEOUT__') {
return {
content: [
{
type: 'text',
text: 'User did not reply to question in intensive chat: Timeout occurred.',
},
],
};
}
// Empty string means user submitted empty input, non-empty is actual reply
else if (answer === '') {
return {
content: [
{
type: 'text',
text: 'User replied with empty input in intensive chat.',
},
],
};
}
else {
return {
content: [{ type: 'text', text: `User replied: ${answer}` }],
};
}
}
catch (error) {
let errorMessage = 'Failed to ask question in session.';
if (error instanceof Error) {
errorMessage = `Failed to ask question in session: ${error.message}`;
}
else if (typeof error === 'string') {
errorMessage = `Failed to ask question in session: ${error}`;
}
return {
content: [
{
type: 'text',
text: errorMessage,
},
],
};
}
});
}
if (isToolEnabled('stop_intensive_chat')) {
// Use properties from the imported intensiveChatTools object
server.tool('stop_intensive_chat',
// Description is a string here
typeof intensiveChatTools.stop.description === 'function'
? intensiveChatTools.stop.description(globalTimeoutSeconds) // Should not happen, but safe
: intensiveChatTools.stop.description, intensiveChatTools.stop.schema, // Use schema property
async (args) => {
// Use inferred args type
const { sessionId } = args;
// Check if session exists
if (!activeChatSessions.has(sessionId)) {
return {
content: [
{ type: 'text', text: 'Error: Invalid or expired session ID.' },
],
};
}
try {
// Stop the session
const success = await stopIntensiveChatSession(sessionId);
// Remove session from map if successful
if (success) {
activeChatSessions.delete(sessionId);
}
const message = success
? 'Session stopped successfully.'
: 'Session not found or already stopped.';
return { content: [{ type: 'text', text: message }] };
}
catch (error) {
let errorMessage = 'Failed to stop intensive chat session.';
if (error instanceof Error) {
errorMessage = `Failed to stop intensive chat session: ${error.message}`;
}
else if (typeof error === 'string') {
errorMessage = `Failed to stop intensive chat session: ${error}`;
}
return { content: [{ type: 'text', text: errorMessage }] };
}
});
}
// --- End Intensive Chat Tool Registrations ---
// Run the server over stdio
const transport = new StdioServerTransport();
await server.connect(transport);