mcp-claude-duo/mcp-slave/index.js
StillHammer b7766853cf Initial commit - MCP Claude Duo
Permet à deux instances Claude Code de discuter ensemble via MCP.

Architecture:
- broker/ : Serveur HTTP central avec heartbeat
- mcp-master/ : MCP pour envoyer des messages (talk, list_partners)
- mcp-slave/ : MCP pour recevoir et répondre (connect, respond)
- hooks/ : Hook Stop pour capturer les réponses (optionnel)

Features:
- Long-polling sans timeout
- Heartbeat toutes les 30s (fix bug Claude Code SSE)
- Sauvegarde des conversations par date
- ID basé sur le dossier de travail (unique par projet)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 03:42:25 +07:00

283 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { writeFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
const PARTNER_NAME = process.env.PARTNER_NAME || process.env.SLAVE_NAME || "Partner";
// ID persistant basé sur le dossier de travail (unique par projet)
const cwd = process.cwd();
const projectName = cwd.split(/[/\\]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "_");
const partnerId = projectName || PARTNER_NAME.toLowerCase().replace(/[^a-z0-9]/g, "_");
// Fichier state unique pour le hook
const __dirname = dirname(fileURLToPath(import.meta.url));
const stateDir = join(__dirname, "..", ".state");
const stateFile = join(stateDir, "current.json"); // Fichier unique, pas basé sur l'ID
// Créer le dossier state
try {
mkdirSync(stateDir, { recursive: true });
} catch {}
let isConnected = false;
let currentRequestId = null;
/**
* Appel HTTP au broker
* @param {string} path - endpoint
* @param {object} options - fetch options
* @param {number} timeoutMs - timeout en ms (défaut: 30s, 0 = pas de timeout)
*/
async function brokerFetch(path, options = {}, timeoutMs = 30000) {
const url = `${BROKER_URL}${path}`;
const fetchOptions = {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
};
// Ajouter un AbortController pour le timeout si spécifié
if (timeoutMs > 0) {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
fetchOptions.signal = controller.signal;
}
const response = await fetch(url, fetchOptions);
return response.json();
}
/**
* Sauvegarde l'état pour le hook
*/
function saveState() {
const state = {
partnerId,
partnerName: PARTNER_NAME,
currentRequestId,
brokerUrl: BROKER_URL,
};
writeFileSync(stateFile, JSON.stringify(state, null, 2));
}
// Créer le serveur MCP
const server = new Server(
{
name: "mcp-claude-duo-slave",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Liste des tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "connect",
description:
"Se connecte en tant que partenaire et attend les messages. Utilise cet outil pour te connecter puis pour attendre chaque nouveau message de ton interlocuteur.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Ton nom/pseudo pour cette conversation (optionnel)",
},
},
},
},
{
name: "respond",
description:
"Envoie ta réponse à ton interlocuteur. Utilise cet outil après avoir reçu un message pour lui répondre.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Ta réponse à envoyer",
},
},
required: ["message"],
},
},
{
name: "disconnect",
description: "Se déconnecte de la conversation",
inputSchema: {
type: "object",
properties: {},
},
},
],
};
});
// Handler des tools
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "connect": {
try {
// S'enregistrer si pas encore fait
if (!isConnected) {
const name = args.name || PARTNER_NAME;
await brokerFetch("/register", {
method: "POST",
body: JSON.stringify({ partnerId, name }),
});
isConnected = true;
console.error(`[MCP-SLAVE] Registered as ${name} (${partnerId})`);
}
// Attendre un message (long-polling sans timeout)
console.error("[MCP-SLAVE] Waiting for message...");
const response = await brokerFetch(`/wait/${partnerId}`, {}, 0);
if (response.disconnected) {
isConnected = false;
return {
content: [{ type: "text", text: "Déconnecté." }],
};
}
if (!response.hasMessage) {
// Timeout, pas de message - réessayer
return {
content: [
{
type: "text",
text: "Pas de nouveau message. Rappelle `connect` pour continuer à attendre.",
},
],
};
}
// Message reçu !
currentRequestId = response.message.requestId;
saveState(); // Sauvegarder pour le hook
return {
content: [
{
type: "text",
text: `**Message reçu:** ${response.message.content}`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Erreur de connexion au broker: ${error.message}. Le broker est-il lancé ?`,
},
],
isError: true,
};
}
}
case "respond": {
try {
if (!currentRequestId) {
return {
content: [
{
type: "text",
text: "Aucun message en attente de réponse. Utilise `connect` d'abord pour recevoir un message.",
},
],
};
}
// Envoyer la réponse au broker
await brokerFetch("/respond", {
method: "POST",
body: JSON.stringify({
partnerId,
requestId: currentRequestId,
content: args.message,
}),
});
const oldRequestId = currentRequestId;
currentRequestId = null;
saveState();
return {
content: [
{
type: "text",
text: `Réponse envoyée. Utilise \`connect\` pour attendre le prochain message.`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Erreur d'envoi: ${error.message}`,
},
],
isError: true,
};
}
}
case "disconnect": {
try {
if (isConnected) {
await brokerFetch("/disconnect", {
method: "POST",
body: JSON.stringify({ partnerId }),
});
isConnected = false;
}
return {
content: [{ type: "text", text: "Déconnecté." }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
default:
return {
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
isError: true,
};
}
});
// Démarrer
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[MCP-SLAVE] Started (ID: ${partnerId})`);
}
main().catch(console.error);