mcp-claude-duo/mcp-partner/index.js
StillHammer 0bb8af199e v2.0 - Architecture unifiée avec SQLite
- MCP unifié : mcp-partner remplace mcp-master et mcp-slave
- Messages bufferisés : SQLite stocke tout, pas besoin d'être connecté en permanence
- Tools simplifiés : register, talk, check_messages, listen, reply, list_partners, history
- Suppression du hook Stop (plus nécessaire avec reply explicite)
- Heartbeat 30s pour éviter les déconnexions idle
- ID basé sur le nom du dossier (unique par projet)

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

483 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
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;
let lastReceivedRequestId = null; // Pour savoir à quel message répondre
/**
* 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
const server = new Server(
{
name: "mcp-claude-duo-partner",
version: "2.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Liste des 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"],
},
},
],
};
});
// Handler des tools
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,
};
}
});
// Démarrer
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[MCP-PARTNER] Started (ID: ${myId})`);
}
main().catch(console.error);