Files
innervoice/src/mcp-server.ts
RichardDillman c45fe4d509 feat: add auto-start, AFK mode, and comprehensive cross-platform setup
Major Features:
- Auto-start: MCP server now automatically starts Telegram bridge on demand
- AFK Mode: Toggle notifications on/off with /afk slash command
- New telegram_toggle_afk MCP tool for controlling notification state
- Dynamic enable/disable via new /toggle and /status API endpoints

MCP Server Improvements:
- Auto-detects if bridge is running and starts it automatically
- Monitors bridge process health and logs output
- Clean shutdown handling for both MCP server and bridge
- Process spawning with proper environment variable passing

Telegram Bridge Updates:
- Runtime toggle for ENABLED state (was previously static)
- POST /toggle endpoint to toggle notifications with Telegram confirmation
- GET /status endpoint to check current notification state
- Telegram messages sent when state changes (🟢/🔴 indicators)

Documentation:
- Cross-platform setup instructions (Mac, Linux, Windows)
- Claude Code CLI setup guide with claude mcp add commands
- Global vs project-specific MCP configuration explained
- Troubleshooting section for fixing configuration scope issues
- Complete AFK mode usage documentation
- All new API endpoints documented

Slash Commands:
- Created /afk command in .claude/commands/afk.md
- Available in both InnerVoice and ESO-MCP projects

Files Changed:
- src/mcp-server.ts: Auto-start logic and telegram_toggle_afk tool
- src/index.ts: Dynamic ENABLED toggle and new API endpoints
- README.md: Comprehensive setup and troubleshooting guide
- mcp-config.json: Updated with correct absolute path
- .claude/commands/afk.md: New slash command for AFK mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 14:15:46 -05:00

408 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { spawn, ChildProcess } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BRIDGE_URL = process.env.TELEGRAM_BRIDGE_URL || 'http://localhost:3456';
const BRIDGE_PORT = new URL(BRIDGE_URL).port || '3456';
const BRIDGE_HOST = new URL(BRIDGE_URL).hostname || 'localhost';
let bridgeProcess: ChildProcess | null = null;
// Check if the bridge is running
async function isBridgeRunning(): Promise<boolean> {
try {
const response = await fetch(`${BRIDGE_URL}/health`, {
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
}
// Start the Telegram bridge
async function startBridge(): Promise<void> {
console.error('🚀 Starting Telegram bridge...');
// Find the project root (one level up from dist or src)
const projectRoot = join(__dirname, '..');
const bridgeScript = join(projectRoot, 'dist', 'index.js');
// Start the bridge process
bridgeProcess = spawn('node', [bridgeScript], {
env: {
...process.env,
PORT: BRIDGE_PORT,
HOST: BRIDGE_HOST,
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Log bridge output
if (bridgeProcess.stdout) {
bridgeProcess.stdout.on('data', (data) => {
console.error(`[Bridge] ${data.toString().trim()}`);
});
}
if (bridgeProcess.stderr) {
bridgeProcess.stderr.on('data', (data) => {
console.error(`[Bridge] ${data.toString().trim()}`);
});
}
bridgeProcess.on('error', (error) => {
console.error('❌ Bridge process error:', error);
});
bridgeProcess.on('exit', (code) => {
console.error(`⚠️ Bridge process exited with code ${code}`);
bridgeProcess = null;
});
// Wait for the bridge to be ready
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
if (await isBridgeRunning()) {
console.error('✅ Telegram bridge is ready');
return;
}
}
throw new Error('Bridge failed to start after 5 seconds');
}
// Ensure the bridge is running
async function ensureBridge(): Promise<void> {
if (await isBridgeRunning()) {
console.error('✅ Telegram bridge is already running');
return;
}
await startBridge();
}
// Define the Telegram bridge tools
const TOOLS: Tool[] = [
{
name: 'telegram_notify',
description: 'Send a notification to the user via Telegram. Use this to keep the user informed about progress, completion, warnings, or errors.',
inputSchema: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'The notification message to send. Supports Markdown formatting.',
},
priority: {
type: 'string',
enum: ['info', 'success', 'warning', 'error', 'question'],
description: 'Priority level: info ( general), success (✅ completed), warning (⚠️ alert), error (❌ failure), question (❓ needs input)',
default: 'info',
},
},
required: ['message'],
},
},
{
name: 'telegram_ask',
description: 'Ask the user a question via Telegram and wait for their answer. This blocks until the user responds. Use for decisions that require user input.',
inputSchema: {
type: 'object',
properties: {
question: {
type: 'string',
description: 'The question to ask the user. Supports Markdown formatting.',
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default: 300000 = 5 minutes)',
default: 300000,
},
},
required: ['question'],
},
},
{
name: 'telegram_get_messages',
description: 'Retrieve unread messages from the user. Use this to check if the user has sent any messages.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'telegram_reply',
description: 'Send a reply to a user message via Telegram. Use after getting messages to respond to the user.',
inputSchema: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'The reply message. Supports Markdown formatting.',
},
},
required: ['message'],
},
},
{
name: 'telegram_check_health',
description: 'Check the health and status of the Telegram bridge. Returns connection status, unread message count, and pending questions.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'telegram_toggle_afk',
description: 'Toggle InnerVoice AFK mode - enables or disables Telegram notifications. Use this when going away from the system to enable notifications, or when back to disable them.',
inputSchema: {
type: 'object',
properties: {},
},
},
];
// Create the MCP server
const server = new Server(
{
name: 'telegram-bridge',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: TOOLS,
};
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'telegram_notify': {
const { message, priority = 'info' } = args as { message: string; priority?: string };
const response = await fetch(`${BRIDGE_URL}/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, priority }),
});
if (!response.ok) {
const error: any = await response.json();
throw new Error(error.error || 'Failed to send notification');
}
return {
content: [
{
type: 'text',
text: `✅ Notification sent successfully to Telegram (priority: ${priority})`,
},
],
};
}
case 'telegram_ask': {
const { question, timeout = 300000 } = args as { question: string; timeout?: number };
const response = await fetch(`${BRIDGE_URL}/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, timeout }),
});
if (!response.ok) {
const error: any = await response.json();
throw new Error(error.error || 'Failed to ask question');
}
const result: any = await response.json();
return {
content: [
{
type: 'text',
text: `User's answer: ${result.answer}`,
},
],
};
}
case 'telegram_get_messages': {
const response = await fetch(`${BRIDGE_URL}/messages`);
if (!response.ok) {
const error: any = await response.json();
throw new Error(error.error || 'Failed to get messages');
}
const result: any = await response.json();
const messages = result.messages.map((m: any) =>
`[${m.timestamp}] ${m.from}: ${m.message}`
).join('\n');
return {
content: [
{
type: 'text',
text: result.count > 0
? `📬 ${result.count} unread message(s):\n\n${messages}`
: '📭 No unread messages',
},
],
};
}
case 'telegram_reply': {
const { message } = args as { message: string };
const response = await fetch(`${BRIDGE_URL}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error: any = await response.json();
throw new Error(error.error || 'Failed to send reply');
}
return {
content: [
{
type: 'text',
text: '✅ Reply sent successfully to Telegram',
},
],
};
}
case 'telegram_check_health': {
const response = await fetch(`${BRIDGE_URL}/health`);
if (!response.ok) {
throw new Error('Bridge is not responding');
}
const health: any = await response.json();
const statusText = [
`🏥 Telegram Bridge Health Check`,
``,
`Status: ${health.status}`,
`Enabled: ${health.enabled ? '✅' : '❌'}`,
`Chat ID: ${health.chatId}`,
`Unread Messages: ${health.unreadMessages}`,
`Pending Questions: ${health.pendingQuestions}`,
].join('\n');
return {
content: [
{
type: 'text',
text: statusText,
},
],
};
}
case 'telegram_toggle_afk': {
const response = await fetch(`${BRIDGE_URL}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to toggle AFK mode');
}
const result: any = await response.json();
const icon = result.enabled ? '🟢' : '🔴';
const status = result.enabled ? 'ENABLED' : 'DISABLED';
return {
content: [
{
type: 'text',
text: `${icon} InnerVoice AFK mode toggled: ${status}\n\n${result.message}`,
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
content: [
{
type: 'text',
text: `❌ Error: ${errorMessage}`,
},
],
isError: true,
};
}
});
// Cleanup function
function cleanup() {
console.error('\n👋 Shutting down MCP server...');
if (bridgeProcess) {
console.error('🛑 Stopping bridge process...');
bridgeProcess.kill('SIGTERM');
bridgeProcess = null;
}
}
// Handle shutdown signals
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
// Start the server
async function main() {
// Ensure the bridge is running before starting the MCP server
await ensureBridge();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('🚀 Telegram MCP server running on stdio');
}
main().catch((error) => {
console.error('Fatal error:', error);
cleanup();
process.exit(1);
});