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>
This commit is contained in:
commit
b7766853cf
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.state/
|
||||
conversations/
|
||||
*.log
|
||||
156
README.md
Normal file
156
README.md
Normal file
@ -0,0 +1,156 @@
|
||||
# MCP Claude Duo
|
||||
|
||||
Fait discuter deux instances Claude Code ensemble via MCP.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Terminal 1 (Master) Terminal 2 (Slave)
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Claude Code │ │ Claude Code │
|
||||
│ + MCP Master │ ┌───────┐ │ + MCP Slave │
|
||||
│ │ │Broker │ │ + Hook Stop │
|
||||
│ talk("yo") ────┼────────►│ HTTP │─────────►│ │
|
||||
│ │ │ │ │ reçoit "yo" │
|
||||
│ │ │ │ │ répond "salut" │
|
||||
│ reçoit "salut"◄┼─────────│ │◄─────────┼── (hook envoie) │
|
||||
└─────────────────┘ └───────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd mcp-claude-duo
|
||||
npm install
|
||||
```
|
||||
|
||||
## Démarrage
|
||||
|
||||
### 1. Lancer le broker (dans un terminal séparé)
|
||||
|
||||
```bash
|
||||
npm run broker
|
||||
```
|
||||
|
||||
Le broker tourne sur `http://localhost:3210` par défaut.
|
||||
|
||||
### 2. Configurer le Master (Terminal 1)
|
||||
|
||||
Ajoute dans ta config MCP Claude Code (`~/.claude.json` ou settings):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"duo-master": {
|
||||
"command": "node",
|
||||
"args": ["C:/Users/alexi/Documents/projects/mcp-claude-duo/mcp-master/index.js"],
|
||||
"env": {
|
||||
"BROKER_URL": "http://localhost:3210"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configurer le Slave (Terminal 2)
|
||||
|
||||
Config MCP:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"duo-slave": {
|
||||
"command": "node",
|
||||
"args": ["C:/Users/alexi/Documents/projects/mcp-claude-duo/mcp-slave/index.js"],
|
||||
"env": {
|
||||
"BROKER_URL": "http://localhost:3210",
|
||||
"SLAVE_NAME": "Bob"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Config Hook (dans `.claude/settings.json` du projet ou `~/.claude/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": {},
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node C:/Users/alexi/Documents/projects/mcp-claude-duo/hooks/on-stop.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Terminal 2 (Slave) - Se connecter
|
||||
|
||||
```
|
||||
Toi: "Connecte-toi en tant que partenaire"
|
||||
Claude: *utilise connect()* → "Connecté, en attente de messages..."
|
||||
```
|
||||
|
||||
### Terminal 1 (Master) - Parler
|
||||
|
||||
```
|
||||
Toi: "Parle à mon partenaire, dis lui salut"
|
||||
Claude: *utilise talk("Salut !")*
|
||||
→ attend la réponse...
|
||||
→ "Partenaire: Hey ! Ça va ?"
|
||||
```
|
||||
|
||||
### Terminal 2 (Slave) - Reçoit et répond
|
||||
|
||||
```
|
||||
Claude: "Message reçu: Salut !"
|
||||
Claude: "Je lui réponds..." → écrit sa réponse
|
||||
→ Le hook capture et envoie au broker
|
||||
Claude: *utilise connect()* → attend le prochain message
|
||||
```
|
||||
|
||||
## Tools disponibles
|
||||
|
||||
### MCP Master
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `talk(message)` | Envoie un message et attend la réponse |
|
||||
| `list_partners()` | Liste les partenaires connectés |
|
||||
|
||||
### MCP Slave
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `connect(name?)` | Se connecte et attend les messages |
|
||||
| `disconnect()` | Se déconnecte |
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Défaut |
|
||||
|----------|-------------|--------|
|
||||
| `BROKER_URL` | URL du broker | `http://localhost:3210` |
|
||||
| `BROKER_PORT` | Port du broker | `3210` |
|
||||
| `SLAVE_NAME` | Nom du slave | `Partner` |
|
||||
|
||||
## Flow détaillé
|
||||
|
||||
1. **Slave** appelle `connect()` → s'enregistre au broker, attend (long-polling)
|
||||
2. **Master** appelle `talk("message")` → envoie au broker
|
||||
3. **Broker** transmet au slave → `connect()` retourne le message
|
||||
4. **Slave** Claude voit le message et répond naturellement
|
||||
5. **Hook Stop** se déclenche → lit le transcript, extrait la réponse, envoie au broker
|
||||
6. **Broker** retourne la réponse au master
|
||||
7. **Master** `talk()` retourne avec la réponse
|
||||
8. **Slave** rappelle `connect()` pour le prochain message
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
311
broker/index.js
Normal file
311
broker/index.js
Normal file
@ -0,0 +1,311 @@
|
||||
import express from "express";
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const PORT = process.env.BROKER_PORT || 3210;
|
||||
|
||||
// Dossier pour sauvegarder les conversations
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const conversationsDir = join(__dirname, "..", "conversations");
|
||||
try {
|
||||
mkdirSync(conversationsDir, { recursive: true });
|
||||
} catch {}
|
||||
|
||||
// Conversations actives: { visitorId: { date, messages: [] } }
|
||||
const activeConversations = new Map();
|
||||
|
||||
/**
|
||||
* Génère l'ID de conversation basé sur les deux partners et la date
|
||||
*/
|
||||
function getConversationId(partnerId) {
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
return `${partnerId}_${today}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde un message dans la conversation
|
||||
*/
|
||||
function saveMessage(partnerId, from, content) {
|
||||
const convId = getConversationId(partnerId);
|
||||
const convFile = join(conversationsDir, `${convId}.json`);
|
||||
|
||||
let conversation;
|
||||
if (existsSync(convFile)) {
|
||||
conversation = JSON.parse(readFileSync(convFile, "utf-8"));
|
||||
} else {
|
||||
conversation = {
|
||||
id: convId,
|
||||
partnerId,
|
||||
startedAt: new Date().toISOString(),
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
conversation.messages.push({
|
||||
from,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
writeFileSync(convFile, JSON.stringify(conversation, null, 2));
|
||||
return conversation;
|
||||
}
|
||||
|
||||
// Slaves connectés: { id: { name, connectedAt, waitingResponse } }
|
||||
const partners = new Map();
|
||||
|
||||
// Messages en attente pour chaque slave: { partnerId: [{ from, content, timestamp }] }
|
||||
const pendingMessages = new Map();
|
||||
|
||||
// Réponses en attente pour le master: { requestId: { resolve, timeout } }
|
||||
const pendingResponses = new Map();
|
||||
|
||||
// Long-polling requests en attente: { partnerId: { res, timeout } }
|
||||
const waitingPartners = new Map();
|
||||
|
||||
/**
|
||||
* Slave s'enregistre
|
||||
* POST /register
|
||||
* Body: { partnerId, name }
|
||||
*/
|
||||
app.post("/register", (req, res) => {
|
||||
const { partnerId, name } = req.body;
|
||||
|
||||
if (!partnerId) {
|
||||
return res.status(400).json({ error: "partnerId required" });
|
||||
}
|
||||
|
||||
partners.set(partnerId, {
|
||||
name: name || partnerId,
|
||||
connectedAt: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
pendingMessages.set(partnerId, []);
|
||||
|
||||
console.log(`[BROKER] Slave registered: ${name || partnerId} (${partnerId})`);
|
||||
|
||||
res.json({ success: true, message: "Registered" });
|
||||
});
|
||||
|
||||
/**
|
||||
* Slave attend un message (long-polling)
|
||||
* GET /wait/:partnerId
|
||||
*/
|
||||
app.get("/wait/:partnerId", (req, res) => {
|
||||
const { partnerId } = req.params;
|
||||
|
||||
if (!partners.has(partnerId)) {
|
||||
return res.status(404).json({ error: "Slave not registered" });
|
||||
}
|
||||
|
||||
// Check s'il y a déjà un message en attente
|
||||
const messages = pendingMessages.get(partnerId) || [];
|
||||
if (messages.length > 0) {
|
||||
const msg = messages.shift();
|
||||
return res.json({ hasMessage: true, message: msg });
|
||||
}
|
||||
|
||||
// Annuler l'ancien waiting s'il existe
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const old = waitingPartners.get(partnerId);
|
||||
if (old.heartbeat) clearInterval(old.heartbeat);
|
||||
old.res.json({ hasMessage: false, message: null, reason: "reconnect" });
|
||||
}
|
||||
|
||||
// Heartbeat toutes les 30 secondes pour garder la connexion vivante (bug Claude Code)
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": heartbeat\n\n");
|
||||
} catch (e) {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Nettoyer quand la connexion se ferme
|
||||
res.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
waitingPartners.delete(partnerId);
|
||||
// Marquer le partner comme déconnecté (mais garder dans la liste pour permettre reconnexion)
|
||||
if (partners.has(partnerId)) {
|
||||
const info = partners.get(partnerId);
|
||||
info.lastSeen = Date.now();
|
||||
info.status = "disconnected";
|
||||
}
|
||||
console.log(`[BROKER] Connection closed for ${partnerId}`);
|
||||
});
|
||||
|
||||
waitingPartners.set(partnerId, { res, heartbeat });
|
||||
|
||||
// Mettre à jour le status
|
||||
if (partners.has(partnerId)) {
|
||||
const info = partners.get(partnerId);
|
||||
info.lastSeen = Date.now();
|
||||
info.status = "waiting";
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Master envoie un message à un slave
|
||||
* POST /send
|
||||
* Body: { partnerId, content, requestId }
|
||||
*/
|
||||
app.post("/send", (req, res) => {
|
||||
const { partnerId, content, requestId } = req.body;
|
||||
|
||||
if (!partnerId || !content) {
|
||||
return res.status(400).json({ error: "partnerId and content required" });
|
||||
}
|
||||
|
||||
if (!partners.has(partnerId)) {
|
||||
return res.status(404).json({ error: "Slave not found" });
|
||||
}
|
||||
|
||||
const message = {
|
||||
content,
|
||||
requestId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
console.log(`[BROKER] Master -> ${partnerId}: "${content.substring(0, 50)}..."`);
|
||||
|
||||
// Sauvegarder le message du master
|
||||
saveMessage(partnerId, "master", content);
|
||||
|
||||
// Si le slave est en attente (long-polling), lui envoyer directement
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const { res: partnerRes, heartbeat } = waitingPartners.get(partnerId);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
waitingPartners.delete(partnerId);
|
||||
partnerRes.json({ hasMessage: true, message });
|
||||
} else {
|
||||
// Sinon, mettre en queue
|
||||
const messages = pendingMessages.get(partnerId) || [];
|
||||
messages.push(message);
|
||||
pendingMessages.set(partnerId, messages);
|
||||
}
|
||||
|
||||
// Attendre la réponse du slave (pas de timeout)
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
pendingResponses.set(requestId, { resolve, timeout: null });
|
||||
});
|
||||
|
||||
responsePromise.then((response) => {
|
||||
res.json(response);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Slave envoie sa réponse (appelé par le hook Stop)
|
||||
* POST /respond
|
||||
* Body: { partnerId, requestId, content }
|
||||
*/
|
||||
app.post("/respond", (req, res) => {
|
||||
const { partnerId, requestId, content } = req.body;
|
||||
|
||||
console.log(`[BROKER] ${partnerId} responded: "${content.substring(0, 50)}..."`);
|
||||
|
||||
// Sauvegarder la réponse du partner
|
||||
if (partnerId && content) {
|
||||
saveMessage(partnerId, partnerId, content);
|
||||
}
|
||||
|
||||
if (requestId && pendingResponses.has(requestId)) {
|
||||
const { resolve, timeout } = pendingResponses.get(requestId);
|
||||
clearTimeout(timeout);
|
||||
pendingResponses.delete(requestId);
|
||||
resolve({ success: true, content });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Liste les partners connectés
|
||||
* GET /partners
|
||||
*/
|
||||
app.get("/partners", (req, res) => {
|
||||
const list = [];
|
||||
for (const [id, info] of partners) {
|
||||
list.push({ id, ...info });
|
||||
}
|
||||
res.json({ partners: list });
|
||||
});
|
||||
|
||||
/**
|
||||
* Slave se déconnecte
|
||||
* POST /disconnect
|
||||
*/
|
||||
app.post("/disconnect", (req, res) => {
|
||||
const { partnerId } = req.body;
|
||||
|
||||
if (partners.has(partnerId)) {
|
||||
partners.delete(partnerId);
|
||||
pendingMessages.delete(partnerId);
|
||||
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const { res: partnerRes, timeout } = waitingPartners.get(partnerId);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
partnerRes.json({ hasMessage: false, disconnected: true });
|
||||
waitingPartners.delete(partnerId);
|
||||
}
|
||||
|
||||
console.log(`[BROKER] Slave disconnected: ${partnerId}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Liste les conversations sauvegardées
|
||||
* GET /conversations
|
||||
*/
|
||||
app.get("/conversations", (req, res) => {
|
||||
try {
|
||||
const files = readdirSync(conversationsDir).filter((f) => f.endsWith(".json"));
|
||||
const conversations = files.map((f) => {
|
||||
const conv = JSON.parse(readFileSync(join(conversationsDir, f), "utf-8"));
|
||||
return {
|
||||
id: conv.id,
|
||||
partnerId: conv.partnerId,
|
||||
startedAt: conv.startedAt,
|
||||
messageCount: conv.messages.length,
|
||||
};
|
||||
});
|
||||
res.json({ conversations });
|
||||
} catch (error) {
|
||||
res.json({ conversations: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Récupère une conversation spécifique
|
||||
* GET /conversations/:id
|
||||
*/
|
||||
app.get("/conversations/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const convFile = join(conversationsDir, `${id}.json`);
|
||||
|
||||
if (!existsSync(convFile)) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
|
||||
const conversation = JSON.parse(readFileSync(convFile, "utf-8"));
|
||||
res.json(conversation);
|
||||
});
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok", partners: partners.size });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[BROKER] Claude Duo Broker running on http://localhost:${PORT}`);
|
||||
});
|
||||
109
hooks/on-stop.js
Normal file
109
hooks/on-stop.js
Normal file
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Hook "Stop" pour Claude Code
|
||||
* Se déclenche quand Claude finit de répondre
|
||||
* Lit le transcript et envoie la dernière réponse au broker
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const stateDir = join(__dirname, "..", ".state");
|
||||
|
||||
async function main() {
|
||||
// Lire l'input du hook depuis stdin
|
||||
let input = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
|
||||
let hookData;
|
||||
try {
|
||||
hookData = JSON.parse(input);
|
||||
} catch {
|
||||
// Pas de données valides
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { transcript_path } = hookData;
|
||||
|
||||
if (!transcript_path) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Lire le fichier state unique
|
||||
const stateFile = join(stateDir, "current.json");
|
||||
let state = null;
|
||||
try {
|
||||
const content = readFileSync(stateFile, "utf-8");
|
||||
state = JSON.parse(content);
|
||||
} catch {
|
||||
// Pas de state, pas de partner connecté
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!state || !state.currentRequestId) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Lire le transcript
|
||||
let transcript;
|
||||
try {
|
||||
transcript = JSON.parse(readFileSync(transcript_path, "utf-8"));
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Trouver la dernière réponse de l'assistant
|
||||
let lastAssistantMessage = null;
|
||||
|
||||
// Le transcript peut avoir différents formats, essayons de trouver les messages
|
||||
const messages = transcript.messages || transcript;
|
||||
|
||||
if (Array.isArray(messages)) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant" && msg.content) {
|
||||
// Extraire le texte du contenu
|
||||
if (typeof msg.content === "string") {
|
||||
lastAssistantMessage = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Chercher les blocs de texte
|
||||
const textBlocks = msg.content.filter((c) => c.type === "text");
|
||||
if (textBlocks.length > 0) {
|
||||
lastAssistantMessage = textBlocks.map((t) => t.text).join("\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Envoyer la réponse au broker
|
||||
try {
|
||||
await fetch(`${BROKER_URL}/respond`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
partnerId: state.partnerId,
|
||||
requestId: state.currentRequestId,
|
||||
content: lastAssistantMessage,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[HOOK] Error sending response: ${error.message}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
194
mcp-master/index.js
Normal file
194
mcp-master/index.js
Normal file
@ -0,0 +1,194 @@
|
||||
#!/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";
|
||||
|
||||
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
|
||||
|
||||
/**
|
||||
* Appel HTTP au broker
|
||||
*/
|
||||
async function brokerFetch(path, options = {}) {
|
||||
const url = `${BROKER_URL}${path}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Créer le serveur MCP
|
||||
const server = new Server(
|
||||
{
|
||||
name: "mcp-claude-duo-master",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Liste des tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "talk",
|
||||
description:
|
||||
"Envoie un message à ton partenaire Claude et attend sa réponse. Utilise ceci pour avoir une conversation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Le message à envoyer à ton partenaire",
|
||||
},
|
||||
partnerId: {
|
||||
type: "string",
|
||||
description:
|
||||
"L'ID du partenaire (optionnel si un seul partenaire connecté)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_partners",
|
||||
description: "Liste tous les partenaires Claude connectés et disponibles",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Handler des tools
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
switch (name) {
|
||||
case "talk": {
|
||||
try {
|
||||
// Récupérer la liste des slaves si pas de partnerId spécifié
|
||||
let partnerId = args.partnerId;
|
||||
|
||||
if (!partnerId) {
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
if (!partners || partners.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Aucun partenaire connecté. Demande à ton partenaire de se connecter d'abord.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
partnerId = partners[0].id;
|
||||
}
|
||||
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const response = await brokerFetch("/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
partnerId,
|
||||
content: args.message,
|
||||
requestId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `**Partenaire:** ${response.content}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Erreur de communication avec le broker: ${error.message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "list_partners": {
|
||||
try {
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
|
||||
if (!partners || partners.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Aucun partenaire connecté pour le moment.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let text = "**Partenaires connectés:**\n\n";
|
||||
for (const partner of partners) {
|
||||
const connectedSince = Math.round(
|
||||
(Date.now() - partner.connectedAt) / 1000
|
||||
);
|
||||
text += `- **${partner.name}** (ID: ${partner.id}) - connecté depuis ${connectedSince}s\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Erreur: ${error.message}. Le broker est-il lancé ?`,
|
||||
},
|
||||
],
|
||||
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-MASTER] Started");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
282
mcp-slave/index.js
Normal file
282
mcp-slave/index.js
Normal file
@ -0,0 +1,282 @@
|
||||
#!/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);
|
||||
1501
package-lock.json
generated
Normal file
1501
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "mcp-claude-duo",
|
||||
"version": "1.0.0",
|
||||
"description": "Deux instances Claude Code qui discutent ensemble via MCP",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"broker": "node broker/index.js",
|
||||
"start:broker": "node broker/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"keywords": ["mcp", "claude", "duo", "conversation"],
|
||||
"license": "MIT"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user