diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f21451a --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7187537 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index b68bf20..c2482b2 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/broker/db.js b/broker/db.js index 76f2cd5..959ba00 100644 --- a/broker/db.js +++ b/broker/db.js @@ -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, }; diff --git a/broker/index.js b/broker/index.js index 76377e3..67a0923 100644 --- a/broker/index.js +++ b/broker/index.js @@ -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 = ""; + const endMarker = ""; + + 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 = ""; + const endMarker = ""; + + 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}`); }); diff --git a/docs/db-schema.md b/docs/db-schema.md new file mode 100644 index 0000000..b537524 --- /dev/null +++ b/docs/db-schema.md @@ -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__` pour direct, `group__` 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 diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000..a10a5ca --- /dev/null +++ b/docs/schema.sql @@ -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__ ou group__ + 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); diff --git a/mcp-partner/index.js b/mcp-partner/index.js index e0afff3..5ce8f01 100644 --- a/mcp-partner/index.js +++ b/mcp-partner/index.js @@ -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); diff --git a/mcp-partner/shared.js b/mcp-partner/shared.js new file mode 100644 index 0000000..49e166a --- /dev/null +++ b/mcp-partner/shared.js @@ -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, +}; diff --git a/mcp-partner/tools/create_conversation.js b/mcp-partner/tools/create_conversation.js new file mode 100644 index 0000000..e27b58e --- /dev/null +++ b/mcp-partner/tools/create_conversation.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/history.js b/mcp-partner/tools/history.js new file mode 100644 index 0000000..9dd5df9 --- /dev/null +++ b/mcp-partner/tools/history.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/leave_conversation.js b/mcp-partner/tools/leave_conversation.js new file mode 100644 index 0000000..2baf19f --- /dev/null +++ b/mcp-partner/tools/leave_conversation.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/list_conversations.js b/mcp-partner/tools/list_conversations.js new file mode 100644 index 0000000..c1b8efc --- /dev/null +++ b/mcp-partner/tools/list_conversations.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/list_partners.js b/mcp-partner/tools/list_partners.js new file mode 100644 index 0000000..5f9f404 --- /dev/null +++ b/mcp-partner/tools/list_partners.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/listen.js b/mcp-partner/tools/listen.js new file mode 100644 index 0000000..a57b5e8 --- /dev/null +++ b/mcp-partner/tools/listen.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/notifications.js b/mcp-partner/tools/notifications.js new file mode 100644 index 0000000..e9f8254 --- /dev/null +++ b/mcp-partner/tools/notifications.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/register.js b/mcp-partner/tools/register.js new file mode 100644 index 0000000..c0faf95 --- /dev/null +++ b/mcp-partner/tools/register.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/set_status.js b/mcp-partner/tools/set_status.js new file mode 100644 index 0000000..9aca157 --- /dev/null +++ b/mcp-partner/tools/set_status.js @@ -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, + }; + } +} diff --git a/mcp-partner/tools/talk.js b/mcp-partner/tools/talk.js new file mode 100644 index 0000000..ecd482d --- /dev/null +++ b/mcp-partner/tools/talk.js @@ -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, + }; + } +}