@skvil/piertotum 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -19
- package/broker.js +42 -4
- package/logo.png +0 -0
- package/mcp-server.js +37 -10
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="logo.png" width="150" alt="Skvil-Piertotum logo" />
|
|
2
|
+
<img src="https://raw.githubusercontent.com/LCGF00/skvil-piertotum/main/logo.png" width="150" alt="Skvil-Piertotum logo" />
|
|
3
3
|
|
|
4
4
|
# Skvil-Piertotum
|
|
5
5
|
|
|
6
|
-
[](https://www.npmjs.com/package/@skvil/piertotum)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
|
|
10
10
|
**Let your Claude Code instances talk to each other.**
|
|
11
11
|
|
|
12
12
|
Skvil-Piertotum is a lightweight MCP + HTTP broker that connects multiple Claude Code terminals — across projects, machines, WSL, or VMs — so they can exchange messages, share context, and even delegate tasks autonomously.
|
|
13
13
|
|
|
14
|
-
</div>
|
|
15
|
-
|
|
16
14
|
```
|
|
17
15
|
Claude Code (API project) Claude Code (Frontend project)
|
|
18
16
|
│ │
|
|
@@ -29,6 +27,8 @@ Claude Code (API project) Claude Code (Frontend project)
|
|
|
29
27
|
└─────────────┘
|
|
30
28
|
```
|
|
31
29
|
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
32
|
---
|
|
33
33
|
|
|
34
34
|
## How it works
|
|
@@ -49,6 +49,12 @@ When `AUTO_PROCESS=true`, the MCP server polls for incoming messages and uses **
|
|
|
49
49
|
|
|
50
50
|
**Requirements:** Node.js 18 or later (`node --version` to check).
|
|
51
51
|
|
|
52
|
+
**Option A — via npm (recommended):**
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g @skvil/piertotum
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Option B — from source:**
|
|
52
58
|
```bash
|
|
53
59
|
git clone https://github.com/LCGF00/skvil-piertotum
|
|
54
60
|
cd skvil-piertotum
|
|
@@ -60,11 +66,17 @@ npm install
|
|
|
60
66
|
Run this once, on any machine accessible to your terminals:
|
|
61
67
|
|
|
62
68
|
```bash
|
|
63
|
-
|
|
69
|
+
# if installed globally:
|
|
70
|
+
skvil-piertotum-broker
|
|
71
|
+
|
|
72
|
+
# without installing (npx):
|
|
73
|
+
npx --package=@skvil/piertotum skvil-piertotum-broker
|
|
74
|
+
|
|
64
75
|
# custom port:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
skvil-piertotum-broker 5000
|
|
77
|
+
|
|
78
|
+
# from source:
|
|
79
|
+
node broker.js
|
|
68
80
|
```
|
|
69
81
|
|
|
70
82
|
The broker listens on `0.0.0.0` — reachable from any IP on your network.
|
|
@@ -82,7 +94,7 @@ claude mcp add skvil-piertotum \
|
|
|
82
94
|
-e AGENT_ID=api \
|
|
83
95
|
-e AGENT_NAME="API Project" \
|
|
84
96
|
-e PROJECT_NAME="my-saas" \
|
|
85
|
-
--
|
|
97
|
+
-- skvil-piertotum-mcp
|
|
86
98
|
|
|
87
99
|
# Terminal 2 — Frontend project
|
|
88
100
|
claude mcp add skvil-piertotum \
|
|
@@ -90,9 +102,12 @@ claude mcp add skvil-piertotum \
|
|
|
90
102
|
-e AGENT_ID=front \
|
|
91
103
|
-e AGENT_NAME="Frontend Project" \
|
|
92
104
|
-e PROJECT_NAME="my-saas" \
|
|
93
|
-
--
|
|
105
|
+
-- skvil-piertotum-mcp
|
|
94
106
|
```
|
|
95
107
|
|
|
108
|
+
> Not installed globally? Replace `skvil-piertotum-mcp` with:
|
|
109
|
+
> `npx --package=@skvil/piertotum skvil-piertotum-mcp`
|
|
110
|
+
|
|
96
111
|
If everything is on the same machine, use `BROKER_URL=http://localhost:4800`.
|
|
97
112
|
|
|
98
113
|
### 4. Or configure via `~/.claude.json`
|
|
@@ -102,8 +117,7 @@ If everything is on the same machine, use `BROKER_URL=http://localhost:4800`.
|
|
|
102
117
|
"mcpServers": {
|
|
103
118
|
"skvil-piertotum": {
|
|
104
119
|
"type": "stdio",
|
|
105
|
-
"command": "
|
|
106
|
-
"args": ["/absolute/path/to/skvil-piertotum/mcp-server.js"],
|
|
120
|
+
"command": "skvil-piertotum-mcp",
|
|
107
121
|
"env": {
|
|
108
122
|
"BROKER_URL": "http://192.168.1.10:4800",
|
|
109
123
|
"AGENT_ID": "api",
|
|
@@ -265,14 +279,14 @@ Full endpoint reference:
|
|
|
265
279
|
```
|
|
266
280
|
POST /agents/register Register an agent
|
|
267
281
|
GET /agents List agents
|
|
268
|
-
POST /agents/:
|
|
269
|
-
DELETE /agents/:
|
|
282
|
+
POST /agents/:agentId/heartbeat Heartbeat (404 if not registered)
|
|
283
|
+
DELETE /agents/:agentId Deregister agent
|
|
270
284
|
|
|
271
285
|
POST /messages/send Send to one agent
|
|
272
286
|
POST /messages/broadcast Send to all agents except sender
|
|
273
|
-
GET /messages/:
|
|
274
|
-
POST /messages/:
|
|
275
|
-
DELETE /messages/:
|
|
287
|
+
GET /messages/:agentId Read messages (?unread=true, ?limit=N)
|
|
288
|
+
POST /messages/:agentId/ack Mark message IDs as read
|
|
289
|
+
DELETE /messages/:agentId Clear all messages
|
|
276
290
|
|
|
277
291
|
POST /context Save context entry
|
|
278
292
|
GET /context List context keys
|
|
@@ -287,7 +301,7 @@ GET /status Broker overview
|
|
|
287
301
|
## Design Notes
|
|
288
302
|
|
|
289
303
|
- **In-memory only** — all state is lost if the broker restarts. Agents re-register automatically on the next heartbeat (within 30s). For persistence, adapt the broker to use SQLite.
|
|
290
|
-
- **Resource limits** — max 100 agents, 200 messages per queue (oldest dropped), 1000 context keys, 100 KB per context value.
|
|
304
|
+
- **Resource limits** — max 100 agents, 200 messages per queue (oldest dropped), 1000 context keys, 100 KB per context value, 512 KB per message.
|
|
291
305
|
- **Stale agent cleanup** — agents that miss 3 heartbeats (90s) are automatically removed.
|
|
292
306
|
- **Message types** — `text`, `code`, `schema`, `endpoint`, `config`. Used by agents to route and handle responses appropriately.
|
|
293
307
|
- **Prompt injection protection** — in autonomous mode, incoming message content is wrapped in XML tags with a random nonce before being injected into Claude's context.
|
package/broker.js
CHANGED
|
@@ -33,7 +33,12 @@ process.on('unhandledRejection', (reason) => {
|
|
|
33
33
|
const app = express();
|
|
34
34
|
app.use(express.json({ limit: '5mb' }));
|
|
35
35
|
|
|
36
|
-
const
|
|
36
|
+
const _rawPort = process.env.BROKER_PORT || process.argv[2] || 4800;
|
|
37
|
+
const PORT = Number(_rawPort);
|
|
38
|
+
if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
|
|
39
|
+
_error(`[ERRO] Porta inválida: "${_rawPort}". Use um número entre 1 e 65535.`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
37
42
|
|
|
38
43
|
// ══════════════════════════════════════════════
|
|
39
44
|
// Limites de recursos
|
|
@@ -102,7 +107,8 @@ app.use((req, res, next) => {
|
|
|
102
107
|
(req.method === 'POST' && req.path.endsWith('/heartbeat'));
|
|
103
108
|
if (!isPolling) {
|
|
104
109
|
const ts = new Date().toLocaleTimeString('pt-BR');
|
|
105
|
-
|
|
110
|
+
const safePath = req.originalUrl.replace(/[\x00-\x1f\x7f]/g, '');
|
|
111
|
+
console.log(`[${ts}] ${req.method} ${safePath}`);
|
|
106
112
|
}
|
|
107
113
|
next();
|
|
108
114
|
});
|
|
@@ -116,6 +122,9 @@ app.post('/agents/register', (req, res) => {
|
|
|
116
122
|
if (!agentId || !name) {
|
|
117
123
|
return res.status(400).json({ error: 'agentId e name são obrigatórios' });
|
|
118
124
|
}
|
|
125
|
+
if (typeof agentId !== 'string' || typeof name !== 'string') {
|
|
126
|
+
return res.status(400).json({ error: 'agentId e name devem ser strings' });
|
|
127
|
+
}
|
|
119
128
|
|
|
120
129
|
if (!agents.has(agentId) && agents.size >= MAX_AGENTS) {
|
|
121
130
|
return res.status(429).json({ error: `Limite de ${MAX_AGENTS} agentes atingido` });
|
|
@@ -190,6 +199,9 @@ app.post('/messages/send', (req, res) => {
|
|
|
190
199
|
if (!from || !to || !content) {
|
|
191
200
|
return res.status(400).json({ error: 'from, to e content são obrigatórios' });
|
|
192
201
|
}
|
|
202
|
+
if (typeof from !== 'string' || typeof to !== 'string' || typeof content !== 'string') {
|
|
203
|
+
return res.status(400).json({ error: 'from, to e content devem ser strings' });
|
|
204
|
+
}
|
|
193
205
|
|
|
194
206
|
if (!agents.has(from) && from !== 'broker') {
|
|
195
207
|
return res.status(400).json({ error: `Remetente "${from}" não registrado. Registre-se antes de enviar mensagens.` });
|
|
@@ -227,6 +239,9 @@ app.post('/messages/broadcast', (req, res) => {
|
|
|
227
239
|
if (!from || !content) {
|
|
228
240
|
return res.status(400).json({ error: 'from e content são obrigatórios' });
|
|
229
241
|
}
|
|
242
|
+
if (typeof from !== 'string' || typeof content !== 'string') {
|
|
243
|
+
return res.status(400).json({ error: 'from e content devem ser strings' });
|
|
244
|
+
}
|
|
230
245
|
|
|
231
246
|
if (!agents.has(from) && from !== 'broker') {
|
|
232
247
|
return res.status(400).json({ error: `Remetente "${from}" não registrado. Registre-se antes de enviar mensagens.` });
|
|
@@ -261,6 +276,9 @@ app.post('/messages/broadcast', (req, res) => {
|
|
|
261
276
|
// ?unread=true → apenas não lidas | ?limit=N → máximo N mensagens
|
|
262
277
|
// Não marca como lidas — use POST /messages/:agentId/ack para confirmar recebimento.
|
|
263
278
|
app.get('/messages/:agentId', (req, res) => {
|
|
279
|
+
if (!agents.has(req.params.agentId)) {
|
|
280
|
+
return res.status(404).json({ error: `Agente "${req.params.agentId}" não registrado` });
|
|
281
|
+
}
|
|
264
282
|
const queue = messages.get(req.params.agentId) || [];
|
|
265
283
|
const unreadOnly = req.query.unread === 'true';
|
|
266
284
|
const limit = req.query.limit ? Math.max(1, parseInt(req.query.limit, 10) || 50) : null;
|
|
@@ -274,6 +292,9 @@ app.get('/messages/:agentId', (req, res) => {
|
|
|
274
292
|
|
|
275
293
|
// ACK — marca mensagens específicas como lidas
|
|
276
294
|
app.post('/messages/:agentId/ack', (req, res) => {
|
|
295
|
+
if (!agents.has(req.params.agentId)) {
|
|
296
|
+
return res.status(404).json({ error: `Agente "${req.params.agentId}" não registrado` });
|
|
297
|
+
}
|
|
277
298
|
const { ids } = req.body;
|
|
278
299
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
279
300
|
return res.status(400).json({ error: 'ids deve ser um array não-vazio de message IDs' });
|
|
@@ -365,6 +386,15 @@ app.use((req, res) => {
|
|
|
365
386
|
res.status(404).json({ error: `Rota não encontrada: ${req.method} ${req.path}` });
|
|
366
387
|
});
|
|
367
388
|
|
|
389
|
+
// Error handler — retorna JSON em vez de HTML (ex: body JSON malformado)
|
|
390
|
+
app.use((err, req, res, _next) => {
|
|
391
|
+
if (err.type === 'entity.parse.failed') {
|
|
392
|
+
return res.status(400).json({ error: 'JSON inválido no body da requisição' });
|
|
393
|
+
}
|
|
394
|
+
_error(`[ERRO] ${err.stack || err.message}`);
|
|
395
|
+
res.status(err.status || 500).json({ error: 'Erro interno do servidor' });
|
|
396
|
+
});
|
|
397
|
+
|
|
368
398
|
// ══════════════════════════════════════════════
|
|
369
399
|
// Console interativo do operador
|
|
370
400
|
// ══════════════════════════════════════════════
|
|
@@ -454,8 +484,12 @@ function startConsole() {
|
|
|
454
484
|
});
|
|
455
485
|
|
|
456
486
|
rl.on('close', () => {
|
|
457
|
-
|
|
458
|
-
|
|
487
|
+
// Quando shutdown() chama rl.close(), o httpServer.close() cuida de sair.
|
|
488
|
+
// Só sai aqui se o readline fechou por conta própria (Ctrl+D).
|
|
489
|
+
if (!shuttingDown) {
|
|
490
|
+
_log('\n Broker encerrado.');
|
|
491
|
+
process.exit(0);
|
|
492
|
+
}
|
|
459
493
|
});
|
|
460
494
|
}
|
|
461
495
|
|
|
@@ -505,7 +539,11 @@ httpServer.on('error', (err) => {
|
|
|
505
539
|
// Shutdown gracioso (SIGTERM / SIGINT)
|
|
506
540
|
// ══════════════════════════════════════════════
|
|
507
541
|
|
|
542
|
+
let shuttingDown = false;
|
|
543
|
+
|
|
508
544
|
const shutdown = () => {
|
|
545
|
+
if (shuttingDown) return; // evita dupla execução (SIGTERM + SIGINT rápidos)
|
|
546
|
+
shuttingDown = true;
|
|
509
547
|
_log('\n 🛑 Broker encerrando...');
|
|
510
548
|
if (rl) rl.close();
|
|
511
549
|
httpServer.close(() => {
|
package/logo.png
ADDED
|
Binary file
|
package/mcp-server.js
CHANGED
|
@@ -19,6 +19,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
19
19
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
20
|
import { z } from 'zod';
|
|
21
21
|
import os from 'os';
|
|
22
|
+
import { readFileSync } from 'fs';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
import { dirname, join } from 'path';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const PKG_VERSION = (() => {
|
|
28
|
+
try { return JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version; }
|
|
29
|
+
catch { return '0.0.0'; }
|
|
30
|
+
})();
|
|
22
31
|
|
|
23
32
|
// ══════════════════════════════════════════════
|
|
24
33
|
// Validação de configuração na inicialização
|
|
@@ -38,7 +47,7 @@ function validateBrokerUrl(raw) {
|
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
const BROKER_URL = validateBrokerUrl(process.env.BROKER_URL || 'http://localhost:4800');
|
|
50
|
+
const BROKER_URL = validateBrokerUrl(process.env.BROKER_URL || 'http://localhost:4800').replace(/\/+$/, '');
|
|
42
51
|
const AGENT_ID = process.env.AGENT_ID || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
43
52
|
const AGENT_NAME = process.env.AGENT_NAME || `SP-${AGENT_ID}`;
|
|
44
53
|
const PROJECT_NAME = process.env.PROJECT_NAME || 'unknown';
|
|
@@ -99,7 +108,11 @@ async function brokerFetch(path, options = {}) {
|
|
|
99
108
|
try { body = await res.json(); } catch { body = {}; }
|
|
100
109
|
return { error: body.error || `HTTP ${res.status} ${res.statusText}` };
|
|
101
110
|
}
|
|
102
|
-
|
|
111
|
+
try {
|
|
112
|
+
return await res.json();
|
|
113
|
+
} catch {
|
|
114
|
+
return { error: `Resposta inválida do broker (não é JSON) em ${path}` };
|
|
115
|
+
}
|
|
103
116
|
} catch (err) {
|
|
104
117
|
if (err.name === 'TimeoutError') {
|
|
105
118
|
return { error: `Broker não respondeu em ${FETCH_TIMEOUT_MS / 1000}s` };
|
|
@@ -149,7 +162,7 @@ async function register() {
|
|
|
149
162
|
|
|
150
163
|
const server = new McpServer({
|
|
151
164
|
name: 'skvil-piertotum',
|
|
152
|
-
version:
|
|
165
|
+
version: PKG_VERSION,
|
|
153
166
|
description: 'Comunicação entre instâncias do Claude Code via broker central'
|
|
154
167
|
});
|
|
155
168
|
|
|
@@ -298,7 +311,10 @@ server.tool(
|
|
|
298
311
|
// Marca as mensagens lidas explicitamente (ACK)
|
|
299
312
|
const ids = result.messages.map(m => m.id).filter(Boolean);
|
|
300
313
|
if (ids.length > 0) {
|
|
301
|
-
await brokerPost(`/messages/${AGENT_ID}/ack`, { ids });
|
|
314
|
+
const ackResult = await brokerPost(`/messages/${AGENT_ID}/ack`, { ids });
|
|
315
|
+
if (ackResult.error) {
|
|
316
|
+
process.stderr.write(`⚠️ ACK falhou: ${ackResult.error}\n`);
|
|
317
|
+
}
|
|
302
318
|
}
|
|
303
319
|
|
|
304
320
|
const lines = result.messages.map(m =>
|
|
@@ -356,7 +372,7 @@ server.tool(
|
|
|
356
372
|
key: z.string().describe('Chave do contexto a ler (ex: "api-endpoints")')
|
|
357
373
|
},
|
|
358
374
|
async ({ key }) => {
|
|
359
|
-
const result = await brokerFetch(`/context/${key}`);
|
|
375
|
+
const result = await brokerFetch(`/context/${encodeURIComponent(key)}`);
|
|
360
376
|
|
|
361
377
|
if (result.error) {
|
|
362
378
|
return { content: [{ type: 'text', text: `❌ ${result.error}` }] };
|
|
@@ -583,7 +599,7 @@ async function processMessage(msg) {
|
|
|
583
599
|
|
|
584
600
|
const responseText = sampling.content.type === 'text'
|
|
585
601
|
? sampling.content.text
|
|
586
|
-
:
|
|
602
|
+
: `[resposta não-texto do tipo "${sampling.content.type}" — não suportada pelo modo autônomo]`;
|
|
587
603
|
|
|
588
604
|
// Envia resposta de volta ao remetente
|
|
589
605
|
if (canReply) {
|
|
@@ -642,11 +658,17 @@ async function pollAndProcess() {
|
|
|
642
658
|
|
|
643
659
|
// Processa uma mensagem por vez, em ordem; ACK individual após cada processamento
|
|
644
660
|
for (const msg of result.messages) {
|
|
645
|
-
|
|
646
|
-
|
|
661
|
+
try {
|
|
662
|
+
await processMessage(msg);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
// ACK mesmo em erro para evitar poison message loop (retry infinito)
|
|
665
|
+
process.stderr.write(`⚠️ Erro ao processar mensagem ${msg.id}: ${err.message}\n`);
|
|
666
|
+
}
|
|
667
|
+
// ACK sempre — inclusive se processMessage falhou (evita poison loop)
|
|
647
668
|
if (msg.id) {
|
|
648
669
|
await brokerPost(`/messages/${AGENT_ID}/ack`, { ids: [msg.id] });
|
|
649
670
|
}
|
|
671
|
+
if (!autoProcessEnabled) break; // sampling falhou — não continua o batch
|
|
650
672
|
}
|
|
651
673
|
} finally {
|
|
652
674
|
isProcessing = false;
|
|
@@ -656,8 +678,10 @@ async function pollAndProcess() {
|
|
|
656
678
|
function startAutonomousMode() {
|
|
657
679
|
if (pollTimer) return; // já rodando
|
|
658
680
|
process.stderr.write(`🤖 Modo autônomo ativado — polling a cada ${POLL_INTERVAL_MS / 1000}s\n`);
|
|
659
|
-
pollAndProcess()
|
|
660
|
-
pollTimer = setInterval(
|
|
681
|
+
pollAndProcess().catch(err => process.stderr.write(`⚠️ Erro no poll inicial: ${err.message}\n`));
|
|
682
|
+
pollTimer = setInterval(() => {
|
|
683
|
+
pollAndProcess().catch(err => process.stderr.write(`⚠️ Erro no poll: ${err.message}\n`));
|
|
684
|
+
}, POLL_INTERVAL_MS);
|
|
661
685
|
}
|
|
662
686
|
|
|
663
687
|
// ══════════════════════════════════════════════
|
|
@@ -709,7 +733,10 @@ async function main() {
|
|
|
709
733
|
}, 30000);
|
|
710
734
|
|
|
711
735
|
// Shutdown gracioso — aguarda processamento em andamento antes de sair
|
|
736
|
+
let shuttingDown = false;
|
|
712
737
|
const shutdown = async () => {
|
|
738
|
+
if (shuttingDown) return;
|
|
739
|
+
shuttingDown = true;
|
|
713
740
|
clearInterval(heartbeatTimer);
|
|
714
741
|
if (pollTimer) clearInterval(pollTimer);
|
|
715
742
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skvil/piertotum",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "MCP + HTTP broker for multi-instance Claude Code communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/LCGF00/skvil-piertotum.git"
|
|
12
12
|
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/LCGF00/skvil-piertotum/issues"
|
|
15
|
+
},
|
|
13
16
|
"keywords": [
|
|
14
17
|
"mcp",
|
|
15
18
|
"claude",
|
|
@@ -28,7 +31,8 @@
|
|
|
28
31
|
"broker.js",
|
|
29
32
|
"mcp-server.js",
|
|
30
33
|
"README.md",
|
|
31
|
-
"LICENSE"
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"logo.png"
|
|
32
36
|
],
|
|
33
37
|
"bin": {
|
|
34
38
|
"skvil-piertotum-broker": "broker.js",
|