v3.0 - Conversation-based messaging system

Features:
- Direct conversations (1-to-1) auto-created and permanent
- Group conversations with leave/archive support
- Real-time messaging via long-polling
- Offline notifications via CLAUDE.md
- Auto-registration on MCP startup

Architecture:
- Broker: Express HTTP server + SQLite
- MCP Partner: Modular tools (one file per tool)
- Full documentation and API reference
This commit is contained in:
StillHammer 2026-01-25 02:57:24 +07:00
parent 0bb8af199e
commit 66e5c677ea
19 changed files with 1617 additions and 688 deletions

78
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,78 @@
# Contributing to MCP Claude Duo
Thanks for your interest in contributing!
## Getting Started
1. Fork the repository
2. Clone your fork
3. Install dependencies: `npm install`
4. Start the broker: `npm run broker`
## Development
### Project Structure
```
mcp-claude-duo/
├── broker/ # HTTP server + SQLite
│ ├── index.js # Express routes
│ └── db.js # Database layer
├── mcp-partner/ # MCP server for Claude Code
│ ├── index.js # Entry point
│ ├── shared.js # Shared utilities
│ └── tools/ # One file per MCP tool
└── docs/ # Documentation
```
### Adding a New Tool
1. Create a new file in `mcp-partner/tools/`
2. Export `definition` (tool schema) and `handler` (async function)
3. Import and register in `mcp-partner/index.js`
Example:
```javascript
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "my_tool",
description: "Description of my tool",
inputSchema: {
type: "object",
properties: {
param: { type: "string", description: "A parameter" }
},
required: ["param"]
}
};
export async function handler(args) {
await ensureRegistered();
// Your logic here
return {
content: [{ type: "text", text: "Result" }]
};
}
```
### Testing
```bash
# Start broker
npm run broker
# Test with curl
curl http://localhost:3210/health
```
## Pull Requests
1. Create a feature branch
2. Make your changes
3. Test locally
4. Submit a PR with a clear description
## Issues
Feel free to open issues for bugs, feature requests, or questions.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 alexi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

211
README.md
View File

@ -1,118 +1,207 @@
# MCP Claude Duo
MCP pour faire discuter plusieurs instances Claude Code ensemble.
> Make multiple Claude Code instances talk to each other through conversations.
## Architecture v2
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## Overview
MCP Claude Duo is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables real-time communication between multiple Claude Code instances. Each Claude can send messages, create group conversations, and receive notifications when offline.
### Key Features
- **Direct Conversations** - Auto-created 1-to-1 threads between any two Claude instances
- **Group Conversations** - Create named group chats with multiple participants
- **Real-time Messaging** - Long-polling based instant message delivery
- **Offline Notifications** - Messages are queued and notifications written to `CLAUDE.md`
- **Auto-registration** - Claude instances automatically connect when launched
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Claude A │ │ Broker │ │ Claude B │
│ (projet-a) │◄───►│ HTTP + SQLite │◄───►│ (projet-b) │
│ + mcp-partner │ │ │ │ + mcp-partner │
│ (project-a) │◄───►│ HTTP + SQLite │◄───►│ (project-b) │
│ + mcp-partner │ │ Conversations │ │ + mcp-partner │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
- **Un seul MCP unifié** : `mcp-partner` pour tout le monde
- **Messages bufferisés** : SQLite stocke les messages, pas besoin d'être connecté en permanence
- **Bidirectionnel** : tout le monde peut parler à tout le monde
- **Broker**: Central HTTP server managing conversations and message routing
- **MCP Partner**: MCP server running in each Claude Code instance
## Installation
```bash
git clone https://github.com/YOUR_USER/mcp-claude-duo.git
cd mcp-claude-duo
npm install
```
## Démarrage
## Quick Start
### 1. Lancer le broker
### 1. Start the Broker
```bash
npm run broker
```
Le broker tourne sur `http://localhost:3210` avec une base SQLite dans `data/duo.db`.
The broker runs on `http://localhost:3210`.
### 2. Configurer le MCP (global)
### 2. Configure MCP in Claude Code
**Global (all projects):**
```bash
claude mcp add duo-partner -s user -e BROKER_URL=http://localhost:3210 -- node "CHEMIN/mcp-claude-duo/mcp-partner/index.js"
claude mcp add duo-partner -s user \
-e BROKER_URL=http://localhost:3210 \
-- node "/path/to/mcp-claude-duo/mcp-partner/index.js"
```
Ou par projet :
**Per project (with custom name):**
```bash
cd mon-projet
claude mcp add duo-partner -s project -e BROKER_URL=http://localhost:3210 -e PARTNER_NAME="Mon Nom" -- node "CHEMIN/mcp-claude-duo/mcp-partner/index.js"
claude mcp add duo-partner -s project \
-e BROKER_URL=http://localhost:3210 \
-e PARTNER_NAME="My Project" \
-- node "/path/to/mcp-claude-duo/mcp-partner/index.js"
```
## Tools disponibles
### 3. Start Talking!
In any Claude Code instance:
```
talk("Hello!", to: "other_project")
```
In the other instance:
```
listen()
→ Message received from other_project: "Hello!"
```
## MCP Tools
### Communication
| Tool | Description |
|------|-------------|
| `register(name?)` | S'enregistrer sur le réseau |
| `talk(message, to?)` | Envoyer un message et attendre la réponse |
| `check_messages(wait?)` | Vérifier les messages en attente |
| `listen()` | Écouter en temps réel (long-polling) |
| `reply(message)` | Répondre au dernier message reçu |
| `list_partners()` | Lister les partenaires connectés |
| `history(partnerId, limit?)` | Historique de conversation |
| `register(name?)` | Register with the network (optional, auto on startup) |
| `talk(message, to?, conversation?)` | Send a message |
| `listen(conversation?, timeout?)` | Listen for messages (2-15 min timeout) |
| `list_partners()` | List connected partners |
## Exemples
### Conversations
### Conversation simple
| Tool | Description |
|------|-------------|
| `list_conversations()` | List your conversations |
| `create_conversation(name, participants)` | Create a group conversation |
| `leave_conversation(conversation)` | Leave a group |
| `history(conversation, limit?)` | Get conversation history |
### Settings
| Tool | Description |
|------|-------------|
| `set_status(message?)` | Set your status message |
| `notifications(enabled)` | Enable/disable CLAUDE.md notifications |
## Examples
### Direct Conversation
**Claude A :**
```
register("Alice")
talk("Salut, ça va ?")
→ attend la réponse...
→ "Bob: Oui et toi ?"
```
# Claude A
talk("Hey, can you help with the auth module?", to: "project_b")
**Claude B :**
```
register("Bob")
# Claude B
listen()
→ "Alice: Salut, ça va ?"
reply("Oui et toi ?")
→ 📁 direct_project_a_project_b
[10:30] project_a: Hey, can you help with the auth module?
talk("Sure, what do you need?", to: "project_a")
```
### Messages bufferisés
### Group Conversation
**Claude A envoie même si B n'est pas connecté :**
```
talk("Hey, t'es là ?")
→ message stocké en DB, attend la réponse...
# Claude A creates a group
create_conversation("Backend Team", "project_b, project_c")
→ Created: group_1706123456789_abc123
# Anyone can send to the group
talk("Sprint planning in 5 min", conversation: "group_1706123456789_abc123")
```
**Claude B se connecte plus tard :**
### Filtered Listening
```
check_messages()
→ "Alice: Hey, t'es là ?"
reply("Oui, j'arrive !")
→ Claude A reçoit la réponse
# Listen only to a specific conversation
listen(conversation: "direct_project_a_project_b", timeout: 10)
# Listen to all conversations
listen(timeout: 5)
```
## API Broker
## Project Structure
| Endpoint | Description |
|----------|-------------|
| `POST /register` | S'enregistrer |
| `POST /talk` | Envoyer et attendre réponse |
| `GET /messages/:id` | Récupérer messages non lus |
| `GET /wait/:id` | Long-polling |
| `POST /respond` | Répondre à un message |
| `GET /partners` | Lister les partenaires |
| `GET /history/:a/:b` | Historique entre deux partenaires |
| `GET /health` | Status du broker |
```
mcp-claude-duo/
├── broker/
│ ├── index.js # HTTP server & routes
│ └── db.js # SQLite database layer
├── mcp-partner/
│ ├── index.js # MCP server entry point
│ ├── shared.js # Shared utilities
│ └── tools/ # One file per tool
│ ├── register.js
│ ├── talk.js
│ ├── listen.js
│ └── ...
├── docs/
│ ├── schema.sql # Database schema
│ └── db-schema.md # Schema documentation
└── data/ # SQLite database (gitignored)
```
## Base de données
## API Reference
SQLite dans `data/duo.db` :
### Broker Endpoints
- `partners` : ID, nom, status, dernière connexion
- `messages` : contenu, expéditeur, destinataire, timestamps
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/register` | POST | Register a partner |
| `/talk` | POST | Send a message |
| `/listen/:partnerId` | GET | Long-poll for messages |
| `/conversations` | POST | Create group conversation |
| `/conversations/:partnerId` | GET | List conversations |
| `/conversations/:id/leave` | POST | Leave a conversation |
| `/conversations/:id/messages` | GET | Get conversation history |
| `/partners` | GET | List all partners |
| `/health` | GET | Health check |
## Database
SQLite database with the following tables:
- **partners** - Registered Claude instances
- **conversations** - Direct and group conversations
- **conversation_participants** - Membership tracking
- **messages** - All messages
See [docs/db-schema.md](docs/db-schema.md) for full schema documentation.
## Configuration
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `BROKER_URL` | `http://localhost:3210` | Broker server URL |
| `BROKER_PORT` | `3210` | Broker listen port |
| `PARTNER_NAME` | `Claude` | Display name for the partner |
## Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
MIT
MIT - See [LICENSE](LICENSE) for details.

View File

@ -24,97 +24,172 @@ db.exec(`
CREATE TABLE IF NOT EXISTS partners (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'online'
status TEXT DEFAULT 'online',
status_message TEXT,
notifications_enabled INTEGER DEFAULT 1
);
-- Messages
-- Conversations
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
name TEXT,
type TEXT NOT NULL DEFAULT 'direct',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
is_archived INTEGER DEFAULT 0,
FOREIGN KEY (created_by) REFERENCES partners(id)
);
-- Participants aux conversations
CREATE TABLE IF NOT EXISTS conversation_participants (
conversation_id TEXT NOT NULL,
partner_id TEXT NOT NULL,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_read_at DATETIME,
PRIMARY KEY (conversation_id, partner_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
FOREIGN KEY (partner_id) REFERENCES partners(id)
);
-- Messages (maintenant liés aux conversations)
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
from_id TEXT NOT NULL,
to_id TEXT NOT NULL,
content TEXT NOT NULL,
request_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
delivered_at DATETIME,
response_to INTEGER REFERENCES messages(id),
FOREIGN KEY (from_id) REFERENCES partners(id),
FOREIGN KEY (to_id) REFERENCES partners(id)
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
FOREIGN KEY (from_id) REFERENCES partners(id)
);
-- Index pour les requêtes fréquentes
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id, delivered_at);
CREATE INDEX IF NOT EXISTS idx_messages_request_id ON messages(request_id);
-- Index
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_participants_partner ON conversation_participants(partner_id);
CREATE INDEX IF NOT EXISTS idx_conversations_archived ON conversations(is_archived);
`);
// Génère un ID de conversation directe (déterministe, trié alphabétiquement)
function getDirectConversationId(partnerId1, partnerId2) {
const sorted = [partnerId1, partnerId2].sort();
return `direct_${sorted[0]}_${sorted[1]}`;
}
// Prepared statements
const stmts = {
// Partners
upsertPartner: db.prepare(`
INSERT INTO partners (id, name, last_seen, status)
VALUES (?, ?, CURRENT_TIMESTAMP, 'online')
INSERT INTO partners (id, name, project_path, last_seen, status)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 'online')
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
project_path = excluded.project_path,
last_seen = CURRENT_TIMESTAMP,
status = 'online'
`),
getPartner: db.prepare(`SELECT * FROM partners WHERE id = ?`),
getAllPartners: db.prepare(`SELECT * FROM partners ORDER BY last_seen DESC`),
updatePartnerStatus: db.prepare(`UPDATE partners SET status = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?`),
updatePartnerNotifications: db.prepare(`UPDATE partners SET notifications_enabled = ? WHERE id = ?`),
updatePartnerStatusMessage: db.prepare(`UPDATE partners SET status_message = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?`),
updatePartnerStatus: db.prepare(`
UPDATE partners SET status = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?
// Conversations
createConversation: db.prepare(`
INSERT INTO conversations (id, name, type, created_by)
VALUES (?, ?, ?, ?)
`),
getConversation: db.prepare(`SELECT * FROM conversations WHERE id = ?`),
getConversationsByPartner: db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')) as unread_count
FROM conversations c
JOIN conversation_participants cp ON c.id = cp.conversation_id
WHERE cp.partner_id = ? AND c.is_archived = 0
ORDER BY c.created_at DESC
`),
archiveConversation: db.prepare(`UPDATE conversations SET is_archived = 1 WHERE id = ?`),
// Participants
addParticipant: db.prepare(`
INSERT OR IGNORE INTO conversation_participants (conversation_id, partner_id)
VALUES (?, ?)
`),
removeParticipant: db.prepare(`
DELETE FROM conversation_participants WHERE conversation_id = ? AND partner_id = ?
`),
getParticipants: db.prepare(`
SELECT p.* FROM partners p
JOIN conversation_participants cp ON p.id = cp.partner_id
WHERE cp.conversation_id = ?
`),
countParticipants: db.prepare(`
SELECT COUNT(*) as count FROM conversation_participants WHERE conversation_id = ?
`),
isParticipant: db.prepare(`
SELECT 1 FROM conversation_participants WHERE conversation_id = ? AND partner_id = ?
`),
updateLastRead: db.prepare(`
UPDATE conversation_participants SET last_read_at = CURRENT_TIMESTAMP
WHERE conversation_id = ? AND partner_id = ?
`),
getLastRead: db.prepare(`
SELECT last_read_at FROM conversation_participants
WHERE conversation_id = ? AND partner_id = ?
`),
// Messages
insertMessage: db.prepare(`
INSERT INTO messages (from_id, to_id, content, request_id)
VALUES (?, ?, ?, ?)
INSERT INTO messages (conversation_id, from_id, content)
VALUES (?, ?, ?)
`),
getUndeliveredMessages: db.prepare(`
getMessages: db.prepare(`
SELECT * FROM messages WHERE conversation_id = ?
ORDER BY created_at ASC
LIMIT ?
`),
getMessagesSince: db.prepare(`
SELECT * FROM messages
WHERE to_id = ? AND delivered_at IS NULL
WHERE conversation_id = ? AND created_at > ?
ORDER BY created_at ASC
`),
markDelivered: db.prepare(`
UPDATE messages SET delivered_at = CURRENT_TIMESTAMP WHERE id = ?
getUnreadMessages: db.prepare(`
SELECT m.* FROM messages m
JOIN conversation_participants cp ON m.conversation_id = cp.conversation_id
WHERE cp.partner_id = ? AND m.from_id != ?
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')
ORDER BY m.created_at ASC
`),
getMessageByRequestId: db.prepare(`
SELECT * FROM messages WHERE request_id = ?
`),
insertResponse: db.prepare(`
INSERT INTO messages (from_id, to_id, content, response_to)
VALUES (?, ?, ?, ?)
`),
getResponse: db.prepare(`
SELECT * FROM messages WHERE response_to = ? AND delivered_at IS NULL
`),
markResponseDelivered: db.prepare(`
UPDATE messages SET delivered_at = CURRENT_TIMESTAMP WHERE response_to = ?
`),
// Conversations history
getConversation: db.prepare(`
SELECT * FROM messages
WHERE (from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)
ORDER BY created_at DESC
LIMIT ?
getUnreadMessagesInConv: db.prepare(`
SELECT m.* FROM messages m
JOIN conversation_participants cp ON m.conversation_id = cp.conversation_id
WHERE cp.partner_id = ? AND m.conversation_id = ? AND m.from_id != ?
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')
ORDER BY m.created_at ASC
`),
};
// API
export const DB = {
// Partners
registerPartner(id, name) {
stmts.upsertPartner.run(id, name);
registerPartner(id, name, projectPath = null) {
stmts.upsertPartner.run(id, name, projectPath);
return stmts.getPartner.get(id);
},
@ -134,47 +209,104 @@ export const DB = {
stmts.updatePartnerStatus.run("online", id);
},
setNotificationsEnabled(id, enabled) {
stmts.updatePartnerNotifications.run(enabled ? 1 : 0, id);
},
setStatusMessage(id, message) {
stmts.updatePartnerStatusMessage.run(message, id);
},
// Conversations
getOrCreateDirectConversation(partnerId1, partnerId2) {
const convId = getDirectConversationId(partnerId1, partnerId2);
let conv = stmts.getConversation.get(convId);
if (!conv) {
stmts.createConversation.run(convId, null, "direct", partnerId1);
stmts.addParticipant.run(convId, partnerId1);
stmts.addParticipant.run(convId, partnerId2);
conv = stmts.getConversation.get(convId);
}
return conv;
},
createGroupConversation(name, creatorId, participantIds) {
const convId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
stmts.createConversation.run(convId, name, "group", creatorId);
// Ajouter le créateur et tous les participants
stmts.addParticipant.run(convId, creatorId);
for (const pid of participantIds) {
if (pid !== creatorId) {
stmts.addParticipant.run(convId, pid);
}
}
return stmts.getConversation.get(convId);
},
getConversation(convId) {
return stmts.getConversation.get(convId);
},
getConversationsByPartner(partnerId) {
return stmts.getConversationsByPartner.all(partnerId);
},
getParticipants(convId) {
return stmts.getParticipants.all(convId);
},
isParticipant(convId, partnerId) {
return !!stmts.isParticipant.get(convId, partnerId);
},
addParticipant(convId, partnerId) {
stmts.addParticipant.run(convId, partnerId);
},
leaveConversation(convId, partnerId) {
const conv = stmts.getConversation.get(convId);
if (!conv) return { error: "Conversation not found" };
if (conv.type === "direct") return { error: "Cannot leave a direct conversation" };
stmts.removeParticipant.run(convId, partnerId);
// Vérifier s'il reste des participants
const count = stmts.countParticipants.get(convId).count;
if (count === 0) {
stmts.archiveConversation.run(convId);
return { left: true, archived: true };
}
return { left: true, archived: false };
},
// Messages
sendMessage(fromId, toId, content, requestId = null) {
const result = stmts.insertMessage.run(fromId, toId, content, requestId);
sendMessage(convId, fromId, content) {
const result = stmts.insertMessage.run(convId, fromId, content);
return result.lastInsertRowid;
},
getUndeliveredMessages(toId) {
return stmts.getUndeliveredMessages.all(toId);
getMessages(convId, limit = 50) {
return stmts.getMessages.all(convId, limit);
},
markDelivered(messageId) {
stmts.markDelivered.run(messageId);
getUnreadMessages(partnerId) {
return stmts.getUnreadMessages.all(partnerId, partnerId);
},
// Pour talk() qui attend une réponse
sendAndWaitResponse(fromId, toId, content, requestId) {
stmts.insertMessage.run(fromId, toId, content, requestId);
getUnreadMessagesInConv(partnerId, convId) {
return stmts.getUnreadMessagesInConv.all(partnerId, convId, partnerId);
},
getMessageByRequestId(requestId) {
return stmts.getMessageByRequestId.get(requestId);
markConversationRead(convId, partnerId) {
stmts.updateLastRead.run(convId, partnerId);
},
sendResponse(fromId, toId, content, originalMessageId) {
stmts.insertResponse.run(fromId, toId, content, originalMessageId);
},
getResponse(originalMessageId) {
return stmts.getResponse.get(originalMessageId);
},
markResponseDelivered(originalMessageId) {
stmts.markResponseDelivered.run(originalMessageId);
},
// History
getConversation(partnerId1, partnerId2, limit = 50) {
return stmts.getConversation.all(partnerId1, partnerId2, partnerId2, partnerId1, limit);
},
// Raw access for complex queries
// Raw access
raw: db,
};

View File

@ -1,4 +1,6 @@
import express from "express";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { DB } from "./db.js";
const app = express();
@ -6,151 +8,396 @@ app.use(express.json());
const PORT = process.env.BROKER_PORT || 3210;
// Réponses en attente (pour talk qui attend une réponse)
// { requestId: { resolve, fromId, toId } }
const pendingResponses = new Map();
// Long-polling en attente (pour check_messages)
// { partnerId: { res, heartbeat } }
// Partenaires en écoute (long-polling)
// { visitorId: { res, heartbeat, timeout, conversationId? } }
const waitingPartners = new Map();
/**
* Écrit une notification dans le CLAUDE.md du projet destinataire
*/
function writeNotificationToClaudeMd(partnerId, fromId, convId, content) {
const partner = DB.getPartner(partnerId);
if (!partner || !partner.project_path || !partner.notifications_enabled) {
return;
}
const claudeMdPath = join(partner.project_path, "CLAUDE.md");
const notificationMarker = "<!-- CLAUDE-DUO-NOTIFICATIONS -->";
const endMarker = "<!-- /CLAUDE-DUO-NOTIFICATIONS -->";
const timestamp = new Date().toLocaleString();
const convLabel = convId.startsWith("direct_") ? `DM de ${fromId}` : `[${convId}]`;
const newNotification = `- **[${timestamp}] ${convLabel}:** ${content.substring(0, 200)}${content.length > 200 ? "..." : ""}`;
let claudeMdContent = "";
if (existsSync(claudeMdPath)) {
claudeMdContent = readFileSync(claudeMdPath, "utf-8");
}
const startIdx = claudeMdContent.indexOf(notificationMarker);
const endIdx = claudeMdContent.indexOf(endMarker);
let notificationsSection = `
${notificationMarker}
## PRIORITE: Messages en attente (Claude Duo)
**ACTION REQUISE: Tu as des messages non lus. Utilise \`listen\` pour les lire.**
${newNotification}
${endMarker}`;
if (startIdx !== -1 && endIdx !== -1) {
const existingSection = claudeMdContent.substring(startIdx + notificationMarker.length, endIdx);
const cleanedNotifications = existingSection
.replace("## PRIORITE: Messages en attente (Claude Duo)", "")
.replace(/\*\*ACTION REQUISE:.*\*\*/g, "")
.trim();
notificationsSection = `
${notificationMarker}
## PRIORITE: Messages en attente (Claude Duo)
**ACTION REQUISE: Tu as des messages non lus. Utilise \`listen\` pour les lire.**
${newNotification}
${cleanedNotifications}
${endMarker}`;
const beforeSection = claudeMdContent.substring(0, startIdx);
const afterSection = claudeMdContent.substring(endIdx + endMarker.length);
claudeMdContent = beforeSection.trimEnd() + "\n" + notificationsSection + afterSection;
} else {
claudeMdContent = claudeMdContent.trimEnd() + "\n" + notificationsSection;
}
try {
writeFileSync(claudeMdPath, claudeMdContent);
console.log(`[BROKER] Notification written to ${claudeMdPath}`);
} catch (err) {
console.error(`[BROKER] Failed to write notification to ${claudeMdPath}: ${err.message}`);
}
}
/**
* Supprime les notifications du CLAUDE.md
*/
function clearNotificationsFromClaudeMd(partnerId) {
const partner = DB.getPartner(partnerId);
if (!partner || !partner.project_path) return;
const claudeMdPath = join(partner.project_path, "CLAUDE.md");
if (!existsSync(claudeMdPath)) return;
const notificationMarker = "<!-- CLAUDE-DUO-NOTIFICATIONS -->";
const endMarker = "<!-- /CLAUDE-DUO-NOTIFICATIONS -->";
let claudeMdContent = readFileSync(claudeMdPath, "utf-8");
const startIdx = claudeMdContent.indexOf(notificationMarker);
const endIdx = claudeMdContent.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1) {
const beforeSection = claudeMdContent.substring(0, startIdx);
const afterSection = claudeMdContent.substring(endIdx + endMarker.length);
claudeMdContent = (beforeSection.trimEnd() + afterSection).trim() + "\n";
writeFileSync(claudeMdPath, claudeMdContent);
console.log(`[BROKER] Notifications cleared from ${claudeMdPath}`);
}
}
/**
* Notifie un partenaire en attente qu'il a des messages
*/
function notifyWaitingPartner(partnerId, conversationId = null) {
if (waitingPartners.has(partnerId)) {
const { res, heartbeat, timeout, conversationId: listeningConvId } = waitingPartners.get(partnerId);
// Si le partenaire écoute une conv spécifique, ne notifier que pour celle-là
if (listeningConvId && conversationId && listeningConvId !== conversationId) {
return false;
}
clearInterval(heartbeat);
if (timeout) clearTimeout(timeout);
waitingPartners.delete(partnerId);
// Récupérer les messages non lus
let messages;
if (listeningConvId) {
messages = DB.getUnreadMessagesInConv(partnerId, listeningConvId);
DB.markConversationRead(listeningConvId, partnerId);
} else {
messages = DB.getUnreadMessages(partnerId);
// Marquer toutes les convs comme lues
const convIds = [...new Set(messages.map(m => m.conversation_id))];
for (const cid of convIds) {
DB.markConversationRead(cid, partnerId);
}
}
if (messages.length > 0) {
clearNotificationsFromClaudeMd(partnerId);
}
try {
res.json({ hasMessages: true, messages });
} catch {}
return true;
}
return false;
}
// ============ ROUTES ============
/**
* S'enregistrer
* POST /register
*/
app.post("/register", (req, res) => {
const { partnerId, name } = req.body;
const { partnerId, name, projectPath } = req.body;
if (!partnerId) {
return res.status(400).json({ error: "partnerId required" });
}
const partner = DB.registerPartner(partnerId, name || partnerId);
const partner = DB.registerPartner(partnerId, name || partnerId, projectPath);
console.log(`[BROKER] Registered: ${partner.name} (${partnerId})`);
res.json({ success: true, partner });
});
/**
* Envoyer un message et attendre la réponse
* Envoyer un message dans une conversation
* POST /talk
* Body: { fromId, to?, conversationId?, content }
* - to: pour créer/trouver une conv directe
* - conversationId: pour envoyer dans une conv existante
*/
app.post("/talk", (req, res) => {
const { fromId, toId, content } = req.body;
const { fromId, to, conversationId, content } = req.body;
if (!fromId || !toId || !content) {
return res.status(400).json({ error: "fromId, toId, and content required" });
if (!fromId || !content) {
return res.status(400).json({ error: "fromId and content required" });
}
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!to && !conversationId) {
return res.status(400).json({ error: "Either 'to' or 'conversationId' required" });
}
// Enregistrer le message en DB
const messageId = DB.sendMessage(fromId, toId, content, requestId);
let conv;
let targetIds = [];
console.log(`[BROKER] ${fromId} -> ${toId}: "${content.substring(0, 50)}..."`);
if (conversationId) {
// Envoyer dans une conv existante
conv = DB.getConversation(conversationId);
if (!conv) {
return res.status(404).json({ error: "Conversation not found" });
}
if (!DB.isParticipant(conversationId, fromId)) {
return res.status(403).json({ error: "Not a participant of this conversation" });
}
targetIds = DB.getParticipants(conversationId).map(p => p.id).filter(id => id !== fromId);
} else {
// Conversation directe
const recipient = DB.getPartner(to);
if (!recipient) {
return res.status(404).json({
error: "Destinataire inconnu",
message: `"${to}" n'est pas enregistré. Il doit se register d'abord.`
});
}
conv = DB.getOrCreateDirectConversation(fromId, to);
targetIds = [to];
}
// Notifier le destinataire s'il est en attente
notifyWaitingPartner(toId);
// Envoyer le message
const msgId = DB.sendMessage(conv.id, fromId, content);
console.log(`[BROKER] ${fromId} -> ${conv.id}: "${content.substring(0, 50)}..."`);
// Attendre la réponse (pas de timeout)
const responsePromise = new Promise((resolve) => {
pendingResponses.set(requestId, { resolve, fromId, toId, messageId });
});
// Notifier les participants
let notifiedCount = 0;
for (const targetId of targetIds) {
const notified = notifyWaitingPartner(targetId, conv.id);
if (notified) {
notifiedCount++;
} else {
// Pas en écoute, écrire notification
writeNotificationToClaudeMd(targetId, fromId, conv.id, content);
}
}
responsePromise.then((response) => {
res.json(response);
res.json({
success: true,
conversationId: conv.id,
messageId: msgId,
notified: notifiedCount,
queued: targetIds.length - notifiedCount
});
});
/**
* Récupérer les messages non lus
* GET /messages/:partnerId
* Écouter les messages (long-polling)
* GET /listen/:partnerId?conversationId=xxx&timeout=5
*/
app.get("/messages/:partnerId", (req, res) => {
app.get("/listen/:partnerId", (req, res) => {
const { partnerId } = req.params;
const { conversationId } = req.query;
const messages = DB.getUndeliveredMessages(partnerId);
// Timeout en minutes (min 2, max 15, défaut 2)
let timeoutMinutes = parseInt(req.query.timeout) || 2;
timeoutMinutes = Math.max(2, Math.min(15, timeoutMinutes));
const timeoutMs = timeoutMinutes * 60 * 1000;
// Marquer comme délivrés
for (const msg of messages) {
DB.markDelivered(msg.id);
}
res.json({ messages });
});
/**
* Attendre des messages (long-polling)
* GET /wait/:partnerId
*/
app.get("/wait/:partnerId", (req, res) => {
const { partnerId } = req.params;
// Mettre à jour le status
DB.setPartnerOnline(partnerId);
// Check s'il y a des messages en attente
const messages = DB.getUndeliveredMessages(partnerId);
if (messages.length > 0) {
// Marquer comme délivrés
for (const msg of messages) {
DB.markDelivered(msg.id);
// Vérifier s'il y a des messages non lus
let messages;
if (conversationId) {
if (!DB.isParticipant(conversationId, partnerId)) {
return res.status(403).json({ error: "Not a participant of this conversation" });
}
messages = DB.getUnreadMessagesInConv(partnerId, conversationId);
} else {
messages = DB.getUnreadMessages(partnerId);
}
if (messages.length > 0) {
// Marquer comme lu
const convIds = [...new Set(messages.map(m => m.conversation_id))];
for (const cid of convIds) {
DB.markConversationRead(cid, partnerId);
}
clearNotificationsFromClaudeMd(partnerId);
return res.json({ hasMessages: true, messages });
}
// Annuler l'ancien waiting s'il existe
// Pas de messages, on attend
if (waitingPartners.has(partnerId)) {
const old = waitingPartners.get(partnerId);
if (old.heartbeat) clearInterval(old.heartbeat);
old.res.json({ hasMessages: false, messages: [], reason: "reconnect" });
if (old.timeout) clearTimeout(old.timeout);
try {
old.res.json({ hasMessages: false, messages: [], reason: "reconnect" });
} catch {}
}
// Heartbeat toutes les 30s
const heartbeat = setInterval(() => {
try {
res.write(": heartbeat\n\n");
} catch (e) {
clearInterval(heartbeat);
const timeout = setTimeout(() => {
if (waitingPartners.has(partnerId)) {
const waiting = waitingPartners.get(partnerId);
clearInterval(waiting.heartbeat);
waitingPartners.delete(partnerId);
try {
res.json({ hasMessages: false, messages: [], reason: "timeout", timeoutMinutes });
} catch {}
}
}, 30000);
}, timeoutMs);
const heartbeat = setInterval(() => {}, 30000);
// Nettoyer quand la connexion se ferme
res.on("close", () => {
clearInterval(heartbeat);
clearTimeout(timeout);
waitingPartners.delete(partnerId);
DB.setPartnerOffline(partnerId);
console.log(`[BROKER] ${partnerId} disconnected`);
});
waitingPartners.set(partnerId, { res, heartbeat });
waitingPartners.set(partnerId, { res, heartbeat, timeout, conversationId });
console.log(`[BROKER] ${partnerId} is now listening${conversationId ? ` on ${conversationId}` : ""}`);
});
/**
* Répondre à un message
* POST /respond
* Créer une conversation de groupe
* POST /conversations
* Body: { creatorId, name, participants: [] }
*/
app.post("/respond", (req, res) => {
const { fromId, toId, content, requestId } = req.body;
app.post("/conversations", (req, res) => {
const { creatorId, name, participants } = req.body;
console.log(`[BROKER] ${fromId} responded to ${toId}: "${content.substring(0, 50)}..."`);
// Trouver la requête en attente
if (requestId && pendingResponses.has(requestId)) {
const { resolve, messageId } = pendingResponses.get(requestId);
pendingResponses.delete(requestId);
// Enregistrer la réponse en DB
DB.sendResponse(fromId, toId, content, messageId);
resolve({ success: true, content });
} else {
// Pas de requête en attente, juste enregistrer comme message normal
DB.sendMessage(fromId, toId, content, null);
notifyWaitingPartner(toId);
if (!creatorId || !name || !participants?.length) {
return res.status(400).json({ error: "creatorId, name, and participants required" });
}
res.json({ success: true });
// Vérifier que tous les participants existent
for (const pid of participants) {
if (!DB.getPartner(pid)) {
return res.status(404).json({ error: `Partner "${pid}" not found` });
}
}
const conv = DB.createGroupConversation(name, creatorId, participants);
console.log(`[BROKER] Group conversation created: ${conv.id} by ${creatorId}`);
res.json({ success: true, conversation: conv });
});
/**
* Lister les conversations d'un partenaire
* GET /conversations/:partnerId
*/
app.get("/conversations/:partnerId", (req, res) => {
const { partnerId } = req.params;
const conversations = DB.getConversationsByPartner(partnerId);
// Ajouter les participants à chaque conversation
const convsWithParticipants = conversations.map(conv => ({
...conv,
participants: DB.getParticipants(conv.id).map(p => ({ id: p.id, name: p.name }))
}));
res.json({ conversations: convsWithParticipants });
});
/**
* Quitter une conversation
* POST /conversations/:conversationId/leave
* Body: { partnerId }
*/
app.post("/conversations/:conversationId/leave", (req, res) => {
const { conversationId } = req.params;
const { partnerId } = req.body;
if (!partnerId) {
return res.status(400).json({ error: "partnerId required" });
}
const result = DB.leaveConversation(conversationId, partnerId);
if (result.error) {
return res.status(400).json({ error: result.error });
}
console.log(`[BROKER] ${partnerId} left ${conversationId}${result.archived ? " (archived)" : ""}`);
res.json({ success: true, ...result });
});
/**
* Obtenir l'historique d'une conversation
* GET /conversations/:conversationId/messages?limit=50
*/
app.get("/conversations/:conversationId/messages", (req, res) => {
const { conversationId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const conv = DB.getConversation(conversationId);
if (!conv) {
return res.status(404).json({ error: "Conversation not found" });
}
const messages = DB.getMessages(conversationId, limit);
res.json({ conversation: conv, messages });
});
/**
* Obtenir les participants d'une conversation
* GET /conversations/:conversationId/participants
*/
app.get("/conversations/:conversationId/participants", (req, res) => {
const { conversationId } = req.params;
const participants = DB.getParticipants(conversationId);
res.json({ participants });
});
/**
@ -158,20 +405,33 @@ app.post("/respond", (req, res) => {
* GET /partners
*/
app.get("/partners", (req, res) => {
const partners = DB.getAllPartners();
const partners = DB.getAllPartners().map((p) => ({
...p,
isListening: waitingPartners.has(p.id),
}));
res.json({ partners });
});
/**
* Historique de conversation
* GET /history/:partner1/:partner2
* Définir le status message d'un partenaire
* POST /partners/:partnerId/status
*/
app.get("/history/:partner1/:partner2", (req, res) => {
const { partner1, partner2 } = req.params;
const limit = parseInt(req.query.limit) || 50;
app.post("/partners/:partnerId/status", (req, res) => {
const { partnerId } = req.params;
const { message } = req.body;
DB.setStatusMessage(partnerId, message || null);
res.json({ success: true });
});
const messages = DB.getConversation(partner1, partner2, limit);
res.json({ messages });
/**
* Activer/désactiver les notifications
* POST /partners/:partnerId/notifications
*/
app.post("/partners/:partnerId/notifications", (req, res) => {
const { partnerId } = req.params;
const { enabled } = req.body;
DB.setNotificationsEnabled(partnerId, enabled);
res.json({ success: true });
});
/**
@ -180,28 +440,10 @@ app.get("/history/:partner1/:partner2", (req, res) => {
app.get("/health", (req, res) => {
const partners = DB.getAllPartners();
const online = partners.filter((p) => p.status === "online").length;
res.json({ status: "ok", partners: partners.length, online });
const listening = waitingPartners.size;
res.json({ status: "ok", partners: partners.length, online, listening });
});
/**
* Notifie un partenaire en attente qu'il a des messages
*/
function notifyWaitingPartner(partnerId) {
if (waitingPartners.has(partnerId)) {
const { res, heartbeat } = waitingPartners.get(partnerId);
clearInterval(heartbeat);
waitingPartners.delete(partnerId);
const messages = DB.getUndeliveredMessages(partnerId);
for (const msg of messages) {
DB.markDelivered(msg.id);
}
res.json({ hasMessages: true, messages });
}
}
app.listen(PORT, () => {
console.log(`[BROKER] Claude Duo Broker v2 running on http://localhost:${PORT}`);
console.log(`[BROKER] Database: data/duo.db`);
console.log(`[BROKER] Claude Duo Broker v3 (Conversations) running on http://localhost:${PORT}`);
});

110
docs/db-schema.md Normal file
View File

@ -0,0 +1,110 @@
# Structure de la Base de Données v3
La base SQLite est créée automatiquement dans `data/duo.db`.
## Tables
### partners
Stocke les informations sur les partenaires (instances Claude Code).
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | TEXT (PK) | Identifiant unique (basé sur le nom du dossier projet) |
| `name` | TEXT | Nom d'affichage du partenaire |
| `project_path` | TEXT | Chemin absolu du projet (pour les notifications CLAUDE.md) |
| `created_at` | DATETIME | Date de première inscription |
| `last_seen` | DATETIME | Dernière activité |
| `status` | TEXT | `online` ou `offline` |
| `status_message` | TEXT | Message de status personnalisé |
| `notifications_enabled` | INTEGER | 1 = activées, 0 = désactivées |
### conversations
Stocke les conversations (directes ou de groupe).
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | TEXT (PK) | `direct_<a>_<b>` pour direct, `group_<ts>_<rand>` pour groupe |
| `name` | TEXT | Nom de la conversation (null pour direct) |
| `type` | TEXT | `direct` ou `group` |
| `created_at` | DATETIME | Date de création |
| `created_by` | TEXT (FK) | Créateur de la conversation |
| `is_archived` | INTEGER | 1 = archivée (plus de participants) |
### conversation_participants
Lie les partenaires aux conversations.
| Colonne | Type | Description |
|---------|------|-------------|
| `conversation_id` | TEXT (PK) | Référence conversation |
| `partner_id` | TEXT (PK) | Référence partenaire |
| `joined_at` | DATETIME | Date d'arrivée |
| `last_read_at` | DATETIME | Dernier message lu (pour calculer les non lus) |
### messages
Stocke tous les messages.
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | INTEGER (PK) | Auto-increment |
| `conversation_id` | TEXT (FK) | Conversation du message |
| `from_id` | TEXT (FK) | Expéditeur |
| `content` | TEXT | Contenu du message |
| `created_at` | DATETIME | Date de création |
## Diagramme ER
```
┌─────────────────────┐
│ partners │
├─────────────────────┤
│ id (PK) │◄────────────────────────────┐
│ name │ │
│ project_path │ │
│ status │ │
│ status_message │ │
│ notifications_enabled│ │
│ created_at │ │
│ last_seen │ │
└─────────────────────┘ │
│ │
│ │
▼ │
┌─────────────────────────────┐ │
│ conversation_participants │ │
├─────────────────────────────┤ │
│ conversation_id (PK, FK) │─────┐ │
│ partner_id (PK, FK) │─────│───────────────┘
│ joined_at │ │
│ last_read_at │ │
└─────────────────────────────┘ │
┌─────────────────────┐ ┌─────────────────────┐
│ conversations │ │ messages │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │◄───│ conversation_id (FK)│
│ name │ │ id (PK) │
│ type │ │ from_id (FK) │───► partners.id
│ created_at │ │ content │
│ created_by (FK) │ │ created_at │
│ is_archived │ └─────────────────────┘
└─────────────────────┘
```
## Conversations directes vs groupe
### Direct (1-to-1)
- ID déterministe: `direct_alice_bob` (trié alphabétiquement)
- Créée automatiquement au premier message
- Impossible à quitter
- Toujours 2 participants
### Groupe
- ID aléatoire: `group_1706123456789_abc123def`
- Créée explicitement via `create_conversation`
- Possibilité de quitter
- Auto-archivée quand plus de participants

52
docs/schema.sql Normal file
View File

@ -0,0 +1,52 @@
-- Schema de la base de données Claude Duo v3 (Conversations)
-- La base est créée automatiquement par broker/db.js
-- Partenaires (instances Claude Code)
CREATE TABLE IF NOT EXISTS partners (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'online',
status_message TEXT,
notifications_enabled INTEGER DEFAULT 1
);
-- Conversations
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY, -- direct_<a>_<b> ou group_<timestamp>_<random>
name TEXT, -- Nom (null pour les direct)
type TEXT NOT NULL DEFAULT 'direct', -- 'direct' ou 'group'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT, -- Créateur (pour les groupes)
is_archived INTEGER DEFAULT 0, -- Archivée quand plus de participants
FOREIGN KEY (created_by) REFERENCES partners(id)
);
-- Participants aux conversations
CREATE TABLE IF NOT EXISTS conversation_participants (
conversation_id TEXT NOT NULL,
partner_id TEXT NOT NULL,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_read_at DATETIME, -- Pour calculer les messages non lus
PRIMARY KEY (conversation_id, partner_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
FOREIGN KEY (partner_id) REFERENCES partners(id)
);
-- Messages
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
from_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
FOREIGN KEY (from_id) REFERENCES partners(id)
);
-- Index
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_participants_partner ON conversation_participants(partner_id);
CREATE INDEX IF NOT EXISTS idx_conversations_archived ON conversations(is_archived);

View File

@ -7,60 +7,39 @@ import {
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
const PARTNER_NAME = process.env.PARTNER_NAME || "Claude";
import { brokerFetch, myId, PARTNER_NAME, cwd, setRegistered } from "./shared.js";
// ID 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 myId = projectName || "partner";
// Import all tools
import * as register from "./tools/register.js";
import * as talk from "./tools/talk.js";
import * as listen from "./tools/listen.js";
import * as listPartners from "./tools/list_partners.js";
import * as listConversations from "./tools/list_conversations.js";
import * as createConversation from "./tools/create_conversation.js";
import * as leaveConversation from "./tools/leave_conversation.js";
import * as history from "./tools/history.js";
import * as setStatus from "./tools/set_status.js";
import * as notifications from "./tools/notifications.js";
let isRegistered = false;
let lastReceivedRequestId = null; // Pour savoir à quel message répondre
// Tool registry
const tools = {
register,
talk,
listen,
list_partners: listPartners,
list_conversations: listConversations,
create_conversation: createConversation,
leave_conversation: leaveConversation,
history,
set_status: setStatus,
notifications,
};
/**
* Appel HTTP au broker
*/
async function brokerFetch(path, options = {}, timeoutMs = 0) {
const url = `${BROKER_URL}${path}`;
const fetchOptions = {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
};
if (timeoutMs > 0) {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
fetchOptions.signal = controller.signal;
}
const response = await fetch(url, fetchOptions);
return response.json();
}
/**
* S'enregistrer auprès du broker
*/
async function ensureRegistered() {
if (!isRegistered) {
await brokerFetch("/register", {
method: "POST",
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME }),
});
isRegistered = true;
console.error(`[MCP-PARTNER] Registered as ${PARTNER_NAME} (${myId})`);
}
}
// Créer le serveur MCP
// Create MCP server
const server = new Server(
{
name: "mcp-claude-duo-partner",
version: "2.0.0",
version: "3.0.0",
},
{
capabilities: {
@ -69,414 +48,45 @@ const server = new Server(
}
);
// Liste des tools
// List all tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "register",
description:
"S'enregistre auprès du réseau de conversation. Utilise au début pour te connecter.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Ton nom/pseudo (optionnel)",
},
},
},
},
{
name: "talk",
description:
"Envoie un message à un partenaire et attend sa réponse. Pour initier ou continuer une conversation.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Le message à envoyer",
},
to: {
type: "string",
description: "L'ID du destinataire (optionnel si un seul partenaire)",
},
},
required: ["message"],
},
},
{
name: "check_messages",
description:
"Vérifie s'il y a des messages en attente. Les messages sont bufferisés, donc pas besoin d'écouter en permanence.",
inputSchema: {
type: "object",
properties: {
wait: {
type: "boolean",
description: "Si true, attend qu'un message arrive (long-polling). Sinon retourne immédiatement.",
},
},
},
},
{
name: "reply",
description:
"Répond au dernier message reçu. À utiliser après check_messages quand quelqu'un attend ta réponse.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Ta réponse",
},
},
required: ["message"],
},
},
{
name: "listen",
description:
"Écoute en temps réel les messages entrants (long-polling). Bloque jusqu'à ce qu'un message arrive.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "list_partners",
description: "Liste tous les partenaires connectés au réseau.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "history",
description: "Récupère l'historique de conversation avec un partenaire.",
inputSchema: {
type: "object",
properties: {
partnerId: {
type: "string",
description: "L'ID du partenaire",
},
limit: {
type: "number",
description: "Nombre de messages max (défaut: 20)",
},
},
required: ["partnerId"],
},
},
],
tools: Object.values(tools).map((t) => t.definition),
};
});
// Handler des tools
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "register": {
try {
const displayName = args.name || PARTNER_NAME;
await brokerFetch("/register", {
method: "POST",
body: JSON.stringify({ partnerId: myId, name: displayName }),
});
isRegistered = true;
return {
content: [
{
type: "text",
text: `Connecté en tant que **${displayName}** (ID: ${myId})`,
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
case "talk": {
try {
await ensureRegistered();
// Trouver le destinataire
let toId = args.to;
if (!toId) {
const { partners } = await brokerFetch("/partners");
const other = partners?.find((p) => p.id !== myId);
if (!other) {
return {
content: [
{
type: "text",
text: "Aucun partenaire connecté. Attends qu'un autre Claude se connecte.",
},
],
};
}
toId = other.id;
}
const response = await brokerFetch("/talk", {
method: "POST",
body: JSON.stringify({
fromId: myId,
toId,
content: args.message,
}),
});
if (response.error) {
return {
content: [{ type: "text", text: `Erreur: ${response.error}` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `**${toId}:** ${response.content}`,
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
case "check_messages": {
try {
await ensureRegistered();
let response;
if (args.wait) {
// Long-polling
response = await brokerFetch(`/wait/${myId}`);
} else {
// Récupération immédiate
response = await brokerFetch(`/messages/${myId}`);
response = { messages: response.messages, hasMessages: response.messages?.length > 0 };
}
if (!response.hasMessages || !response.messages?.length) {
return {
content: [
{
type: "text",
text: "Pas de nouveaux messages.",
},
],
};
}
// Formater les messages
let text = `**${response.messages.length} message(s) reçu(s):**\n\n`;
for (const msg of response.messages) {
text += `**${msg.from_id}:** ${msg.content}\n`;
// Garder le request_id du dernier message pour pouvoir y répondre
if (msg.request_id) {
lastReceivedRequestId = msg.request_id;
}
}
if (lastReceivedRequestId) {
text += `\n_Utilise \`reply\` pour répondre._`;
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
case "reply": {
try {
await ensureRegistered();
if (!lastReceivedRequestId) {
return {
content: [
{
type: "text",
text: "Aucun message en attente de réponse. Utilise `check_messages` d'abord.",
},
],
};
}
// Trouver le destinataire original
const { partners } = await brokerFetch("/partners");
const other = partners?.find((p) => p.id !== myId);
const toId = other?.id || "unknown";
await brokerFetch("/respond", {
method: "POST",
body: JSON.stringify({
fromId: myId,
toId,
content: args.message,
requestId: lastReceivedRequestId,
}),
});
lastReceivedRequestId = null;
return {
content: [
{
type: "text",
text: "Réponse envoyée.",
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
case "listen": {
try {
await ensureRegistered();
// Long-polling - attend qu'un message arrive
console.error("[MCP-PARTNER] Listening...");
const response = await brokerFetch(`/wait/${myId}`);
if (!response.hasMessages || !response.messages?.length) {
return {
content: [
{
type: "text",
text: "Timeout. Rappelle `listen` pour continuer à écouter.",
},
],
};
}
// Formater les messages
let text = "";
for (const msg of response.messages) {
text += `**${msg.from_id}:** ${msg.content}\n`;
if (msg.request_id) {
lastReceivedRequestId = msg.request_id;
}
}
if (lastReceivedRequestId) {
text += `\n_Utilise \`reply\` pour répondre._`;
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
case "list_partners": {
try {
const { partners } = await brokerFetch("/partners");
if (!partners?.length) {
return {
content: [{ type: "text", text: "Aucun partenaire enregistré." }],
};
}
let text = "**Partenaires:**\n\n";
for (const p of partners) {
const status = p.status === "online" ? "🟢" : "⚫";
const isMe = p.id === myId ? " (toi)" : "";
text += `${status} **${p.name}** (${p.id})${isMe}\n`;
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
case "history": {
try {
const limit = args.limit || 20;
const response = await brokerFetch(
`/history/${myId}/${args.partnerId}?limit=${limit}`
);
if (!response.messages?.length) {
return {
content: [
{
type: "text",
text: `Pas d'historique avec ${args.partnerId}.`,
},
],
};
}
let text = `**Historique avec ${args.partnerId}:**\n\n`;
// Inverser pour avoir l'ordre chronologique
const messages = response.messages.reverse();
for (const msg of messages) {
const date = new Date(msg.created_at).toLocaleString();
text += `[${date}] **${msg.from_id}:** ${msg.content}\n\n`;
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}
default:
return {
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
isError: true,
};
const tool = tools[name];
if (!tool) {
return {
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
isError: true,
};
}
return await tool.handler(args || {});
});
// Démarrer
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[MCP-PARTNER] Started (ID: ${myId})`);
// Auto-register on startup
try {
await brokerFetch("/register", {
method: "POST",
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME, projectPath: cwd }),
});
setRegistered(true);
console.error(`[MCP-PARTNER] Auto-registered as ${PARTNER_NAME} (${myId})`);
} catch (error) {
console.error(`[MCP-PARTNER] Auto-register failed: ${error.message}`);
}
}
main().catch(console.error);

58
mcp-partner/shared.js Normal file
View File

@ -0,0 +1,58 @@
// Shared utilities and state for MCP partner
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
const PARTNER_NAME = process.env.PARTNER_NAME || "Claude";
// ID 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 myId = projectName || "partner";
let isRegistered = false;
/**
* Appel HTTP au broker
*/
async function brokerFetch(path, options = {}) {
const url = `${BROKER_URL}${path}`;
const fetchOptions = {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
};
const response = await fetch(url, fetchOptions);
return response.json();
}
/**
* S'enregistrer auprès du broker
*/
async function ensureRegistered() {
if (!isRegistered) {
await brokerFetch("/register", {
method: "POST",
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME, projectPath: cwd }),
});
isRegistered = true;
console.error(`[MCP-PARTNER] Registered as ${PARTNER_NAME} (${myId}) at ${cwd}`);
}
}
function setRegistered(value) {
isRegistered = value;
}
export {
BROKER_URL,
PARTNER_NAME,
cwd,
myId,
isRegistered,
brokerFetch,
ensureRegistered,
setRegistered,
};

View File

@ -0,0 +1,58 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "create_conversation",
description: "Crée une nouvelle conversation de groupe.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Nom de la conversation",
},
participants: {
type: "string",
description: "IDs des participants séparés par des virgules",
},
},
required: ["name", "participants"],
},
};
export async function handler(args) {
try {
await ensureRegistered();
const participantIds = args.participants.split(",").map((s) => s.trim());
const response = await brokerFetch("/conversations", {
method: "POST",
body: JSON.stringify({
creatorId: myId,
name: args.name,
participants: participantIds,
}),
});
if (response.error) {
return {
content: [{ type: "text", text: `Erreur: ${response.error}` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Conversation créée: **${args.name}**\nID: \`${response.conversation.id}\`\nParticipants: ${participantIds.join(", ")}`,
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,61 @@
import { brokerFetch, ensureRegistered } from "../shared.js";
export const definition = {
name: "history",
description: "Récupère l'historique d'une conversation.",
inputSchema: {
type: "object",
properties: {
conversation: {
type: "string",
description: "ID de la conversation",
},
limit: {
type: "number",
description: "Nombre de messages max (défaut: 50)",
},
},
required: ["conversation"],
},
};
export async function handler(args) {
try {
await ensureRegistered();
const limit = args.limit || 50;
const response = await brokerFetch(
`/conversations/${args.conversation}/messages?limit=${limit}`
);
if (response.error) {
return {
content: [{ type: "text", text: `Erreur: ${response.error}` }],
isError: true,
};
}
if (!response.messages?.length) {
return {
content: [{ type: "text", text: `Pas de messages dans cette conversation.` }],
};
}
const convName = response.conversation.name || response.conversation.id;
let text = `**Historique: ${convName}**\n\n`;
for (const msg of response.messages) {
const date = new Date(msg.created_at).toLocaleString();
text += `[${date}] **${msg.from_id}:** ${msg.content}\n\n`;
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,44 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "leave_conversation",
description: "Quitte une conversation de groupe. Impossible de quitter une conv directe.",
inputSchema: {
type: "object",
properties: {
conversation: {
type: "string",
description: "ID de la conversation à quitter",
},
},
required: ["conversation"],
},
};
export async function handler(args) {
try {
await ensureRegistered();
const response = await brokerFetch(`/conversations/${args.conversation}/leave`, {
method: "POST",
body: JSON.stringify({ partnerId: myId }),
});
if (response.error) {
return {
content: [{ type: "text", text: `Erreur: ${response.error}` }],
isError: true,
};
}
const archived = response.archived ? " (conversation archivée car plus de participants)" : "";
return {
content: [{ type: "text", text: `Tu as quitté la conversation.${archived}` }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,42 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "list_conversations",
description: "Liste toutes tes conversations actives.",
inputSchema: {
type: "object",
properties: {},
},
};
export async function handler() {
try {
await ensureRegistered();
const { conversations } = await brokerFetch(`/conversations/${myId}`);
if (!conversations?.length) {
return {
content: [{ type: "text", text: "Aucune conversation." }],
};
}
let text = "**Conversations:**\n\n";
for (const conv of conversations) {
const type = conv.type === "direct" ? "💬" : "👥";
const unread = conv.unread_count > 0 ? ` (${conv.unread_count} non lu${conv.unread_count > 1 ? "s" : ""})` : "";
const participants = conv.participants.map((p) => p.name).join(", ");
const name = conv.name || participants;
text += `${type} **${name}**${unread}\n ID: \`${conv.id}\`\n Participants: ${participants}\n\n`;
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,42 @@
import { brokerFetch, myId } from "../shared.js";
export const definition = {
name: "list_partners",
description: "Liste tous les partenaires connectés au réseau.",
inputSchema: {
type: "object",
properties: {},
},
};
export async function handler() {
try {
const { partners } = await brokerFetch("/partners");
if (!partners?.length) {
return {
content: [{ type: "text", text: "Aucun partenaire enregistré." }],
};
}
let text = "**Partenaires:**\n\n";
for (const p of partners) {
const status = p.status === "online" ? "🟢" : "⚫";
const listening = p.isListening ? " 👂" : "";
const isMe = p.id === myId ? " (toi)" : "";
const statusMsg = p.status_message ? ` — _${p.status_message}_` : "";
text += `${status}${listening} **${p.name}** (${p.id})${isMe}${statusMsg}\n`;
}
text += "\n_Légende: 🟢 en ligne, ⚫ hors ligne, 👂 en écoute_";
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,82 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "listen",
description: "Écoute les messages entrants. Retourne immédiatement s'il y a des messages non lus, sinon attend.",
inputSchema: {
type: "object",
properties: {
conversation: {
type: "string",
description: "ID de la conversation à écouter (optionnel, toutes par défaut)",
},
timeout: {
type: "number",
description: "Timeout en minutes (min: 2, max: 15, défaut: 2)",
},
},
},
};
export async function handler(args) {
try {
await ensureRegistered();
let timeoutMinutes = args.timeout || 2;
timeoutMinutes = Math.max(2, Math.min(15, timeoutMinutes));
let url = `/listen/${myId}?timeout=${timeoutMinutes}`;
if (args.conversation) {
url += `&conversationId=${encodeURIComponent(args.conversation)}`;
}
console.error(`[MCP-PARTNER] Listening (timeout: ${timeoutMinutes}min)...`);
const response = await brokerFetch(url);
if (response.error) {
return {
content: [{ type: "text", text: `Erreur: ${response.error}` }],
isError: true,
};
}
if (!response.hasMessages || !response.messages?.length) {
return {
content: [
{
type: "text",
text: `Timeout après ${response.timeoutMinutes || timeoutMinutes} minutes. Rappelle \`listen\` pour continuer.`,
},
],
};
}
// Grouper par conversation
const byConv = {};
for (const msg of response.messages) {
if (!byConv[msg.conversation_id]) {
byConv[msg.conversation_id] = [];
}
byConv[msg.conversation_id].push(msg);
}
let text = `**${response.messages.length} message(s) reçu(s):**\n\n`;
for (const [convId, msgs] of Object.entries(byConv)) {
text += `📁 **${convId}**\n`;
for (const msg of msgs) {
const time = new Date(msg.created_at).toLocaleTimeString();
text += ` [${time}] **${msg.from_id}:** ${msg.content}\n`;
}
text += "\n";
}
return {
content: [{ type: "text", text }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,37 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "notifications",
description: "Active ou désactive les notifications dans CLAUDE.md.",
inputSchema: {
type: "object",
properties: {
enabled: {
type: "boolean",
description: "true pour activer, false pour désactiver",
},
},
required: ["enabled"],
},
};
export async function handler(args) {
try {
await ensureRegistered();
await brokerFetch(`/partners/${myId}/notifications`, {
method: "POST",
body: JSON.stringify({ enabled: args.enabled }),
});
const status = args.enabled ? "activées" : "désactivées";
return {
content: [{ type: "text", text: `Notifications ${status}.` }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,40 @@
import { brokerFetch, myId, cwd, PARTNER_NAME, setRegistered } from "../shared.js";
export const definition = {
name: "register",
description: "S'enregistre auprès du réseau de conversation. Optionnel car auto-register au démarrage.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Ton nom/pseudo (optionnel)",
},
},
},
};
export async function handler(args) {
try {
const displayName = args.name || PARTNER_NAME;
await brokerFetch("/register", {
method: "POST",
body: JSON.stringify({ partnerId: myId, name: displayName, projectPath: cwd }),
});
setRegistered(true);
return {
content: [
{
type: "text",
text: `Connecté en tant que **${displayName}** (ID: ${myId})\nProjet: ${cwd}`,
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

View File

@ -0,0 +1,41 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "set_status",
description: "Définit ton status visible par les autres partenaires.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Ton status (ex: 'Working on auth module'). Vide pour effacer.",
},
},
},
};
export async function handler(args) {
try {
await ensureRegistered();
await brokerFetch(`/partners/${myId}/status`, {
method: "POST",
body: JSON.stringify({ message: args.message || null }),
});
if (args.message) {
return {
content: [{ type: "text", text: `Status: _${args.message}_` }],
};
} else {
return {
content: [{ type: "text", text: "Status effacé." }],
};
}
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}

90
mcp-partner/tools/talk.js Normal file
View File

@ -0,0 +1,90 @@
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
export const definition = {
name: "talk",
description: "Envoie un message dans une conversation. Crée automatiquement une conv directe si besoin.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Le message à envoyer",
},
to: {
type: "string",
description: "L'ID du destinataire (pour conv directe)",
},
conversation: {
type: "string",
description: "L'ID de la conversation (pour conv existante)",
},
},
required: ["message"],
},
};
export async function handler(args) {
try {
await ensureRegistered();
if (!args.to && !args.conversation) {
// Essayer de trouver un partenaire unique
const { partners } = await brokerFetch("/partners");
const others = partners?.filter((p) => p.id !== myId);
if (!others?.length) {
return {
content: [{ type: "text", text: "Aucun partenaire enregistré. Précise `to` ou `conversation`." }],
isError: true,
};
}
if (others.length === 1) {
args.to = others[0].id;
} else {
return {
content: [
{
type: "text",
text: `Plusieurs partenaires: ${others.map((p) => p.id).join(", ")}. Précise \`to\` ou \`conversation\`.`,
},
],
isError: true,
};
}
}
const response = await brokerFetch("/talk", {
method: "POST",
body: JSON.stringify({
fromId: myId,
to: args.to,
conversationId: args.conversation,
content: args.message,
}),
});
if (response.error) {
return {
content: [{ type: "text", text: `Erreur: ${response.error}\n${response.message || ""}` }],
isError: true,
};
}
const status = response.notified > 0
? `${response.notified} notifié(s) en temps réel`
: `${response.queued} en file d'attente`;
return {
content: [
{
type: "text",
text: `Message envoyé dans ${response.conversationId}\n${status}`,
},
],
};
} catch (error) {
return {
content: [{ type: "text", text: `Erreur: ${error.message}` }],
isError: true,
};
}
}