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