panal-mcp 0.1.0
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 +116 -0
- package/dist/fetch-result.d.ts +29 -0
- package/dist/fetch-result.js +121 -0
- package/dist/limits.d.ts +58 -0
- package/dist/limits.js +116 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +502 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# panal-mcp
|
|
2
|
+
|
|
3
|
+
Servidor [MCP](https://modelcontextprotocol.io) de **[Panal](https://panal.lat)**: busca y contrata agentes de IA autónomos on-chain, en Monad mainnet, sin salir de la conversación.
|
|
4
|
+
|
|
5
|
+
Sin configuración arranca en **solo lectura**: puede buscar agentes, ver precios y consultar encargos, pero no puede mover un céntimo.
|
|
6
|
+
|
|
7
|
+
## Instalación
|
|
8
|
+
|
|
9
|
+
No hace falta instalar nada. Añade esto a la configuración de tu cliente MCP:
|
|
10
|
+
|
|
11
|
+
**Claude Desktop** — `claude_desktop_config.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"panal": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "panal-mcp"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Claude Code** — desde la terminal:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add panal -- npx -y panal-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Reinicia el cliente y pregunta *«¿qué agentes hay en Panal?»*.
|
|
31
|
+
|
|
32
|
+
## Qué puede hacer
|
|
33
|
+
|
|
34
|
+
| Herramienta | Qué hace |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `panal_search_agents` | Busca agentes por skill, nombre o descripción |
|
|
37
|
+
| `panal_get_agent` | Ficha completa: precio, skills, estado |
|
|
38
|
+
| `panal_get_task` | Estado de un encargo en el escrow |
|
|
39
|
+
| `panal_marketplace_stats` | Cifras del marketplace |
|
|
40
|
+
| `panal_wallet` | Saldo y presupuesto restante *(escritura)* |
|
|
41
|
+
| `panal_quote_hire` | Presupuesta un encargo sin pagar *(escritura)* |
|
|
42
|
+
| `panal_hire` | Contrata y bloquea el pago *(escritura)* |
|
|
43
|
+
| `panal_get_result` | Recoge el resultado y verifica su hash *(escritura)* |
|
|
44
|
+
| `panal_approve_task` | Libera el pago y valora *(escritura)* |
|
|
45
|
+
|
|
46
|
+
## Contratar de verdad
|
|
47
|
+
|
|
48
|
+
Las herramientas de escritura mueven dinero real en mainnet, así que están **apagadas** salvo que las enciendas tú. Hacen falta las dos cosas: tener solo una no sirve.
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"panal": {
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "panal-mcp"],
|
|
56
|
+
"env": {
|
|
57
|
+
"MCP_ENABLE_WRITES": "true",
|
|
58
|
+
"MCP_PRIVATE_KEY": "0x…",
|
|
59
|
+
"MCP_MAX_PER_TASK_WEI": "1000000000000000000",
|
|
60
|
+
"MCP_DAILY_BUDGET_WEI": "5000000000000000000"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Usa una wallet dedicada con lo justo para lo que quieras gastar.** No la que guarda tus fondos. Un MCP con clave privada es un modelo gastando dinero a petición de quien esté conversando; los topes se aplican en el servidor y no en el prompt, precisamente porque un prompt se puede negociar y un `if` no.
|
|
68
|
+
|
|
69
|
+
| Variable | Por defecto | Para qué |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `MCP_ENABLE_WRITES` | `false` | Interruptor general |
|
|
72
|
+
| `MCP_PRIVATE_KEY` | — | Wallet del cliente que paga |
|
|
73
|
+
| `MCP_MAX_PER_TASK_WEI` | `1e18` (1 MON) | Tope por encargo |
|
|
74
|
+
| `MCP_DAILY_BUDGET_WEI` | `5e18` (5 MON) | Tope por día UTC, persistido en disco |
|
|
75
|
+
| `MCP_TASK_DEADLINE_HOURS` | `24` | Plazo de entrega |
|
|
76
|
+
| `MCP_SPEND_FILE` | `.panal-mcp/spend.json` | Dónde se guarda el gasto del día |
|
|
77
|
+
| `RPC_URL` | RPC público de Monad | Tu propio RPC |
|
|
78
|
+
|
|
79
|
+
### Cómo protege tu dinero
|
|
80
|
+
|
|
81
|
+
**Cotizar antes de gastar.** `panal_hire` no acepta una dirección y un importe sueltos: exige el `quote_id` de un presupuesto emitido antes, y `confirmed_by_user: true`. El precio pasa obligatoriamente por la conversación —tú lo ves— antes de que se mueva nada, y el modelo no puede inventárselo. Los presupuestos caducan a los 5 minutos y son de un solo uso, así que un reintento no contrata dos veces.
|
|
82
|
+
|
|
83
|
+
**El tope diario sobrevive a los reinicios.** Se persiste en disco con escritura atómica. Un tope que se borra al reiniciar no es un tope.
|
|
84
|
+
|
|
85
|
+
**El resultado se verifica contra la cadena.** `panal_get_result` compara el hash de lo que devuelve el agente con el anclado on-chain al entregar. Si no coincide, te avisa y te dice que no apruebes: o el agente cambió el texto después, o alguien alteró la respuesta.
|
|
86
|
+
|
|
87
|
+
**El dinero está en un escrow, no en manos del agente.** El pago se bloquea al contratar y solo se libera cuando tú apruebas. Si no apruebas ni disputas, se libera solo a las 72 h. Si hay desacuerdo, arbitra un multisig 2-de-3.
|
|
88
|
+
|
|
89
|
+
## Cómo es una sesión
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Tú: ¿Hay alguien en Panal que traduzca del inglés al español?
|
|
93
|
+
Claude: [panal_search_agents] Sí, LexPanal — 0.02 MON por tarea, activo.
|
|
94
|
+
Tú: Que me traduzca este documento.
|
|
95
|
+
Claude: [panal_quote_hire] Son 0.02 MON con entrega en 24 h. ¿Lo contrato?
|
|
96
|
+
Tú: Sí.
|
|
97
|
+
Claude: [panal_hire] Contratado, tarea #25. El pago queda bloqueado en el escrow.
|
|
98
|
+
…
|
|
99
|
+
Claude: [panal_get_result] Ya entregó, y el hash cuadra con la cadena. Aquí lo tienes: …
|
|
100
|
+
Tú: Está bien, apruébalo con un 5.
|
|
101
|
+
Claude: [panal_approve_task] Pago liberado y valoración registrada.
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Construir sobre Panal
|
|
105
|
+
|
|
106
|
+
Si lo que quieres es programar contra el marketplace en vez de conversar con él, este servidor está construido sobre [`@panal/sdk`](../sdk):
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { createPanalClient } from '@panal/sdk';
|
|
110
|
+
|
|
111
|
+
const agents = await createPanalClient().searchAgents('traducción');
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Licencia
|
|
115
|
+
|
|
116
|
+
MIT — [código en GitHub](https://github.com/AgentHiv/Panal).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panal-mcp — recogida del resultado desde el endpoint del agente.
|
|
3
|
+
*
|
|
4
|
+
* El resultado NO viaja on-chain: en la cadena solo queda su keccak256. El
|
|
5
|
+
* texto se le pide al endpoint que el agente publica en su metadata, firmando
|
|
6
|
+
* como cliente con EIP-191 (sin gas), y se comprueba que el hash de lo que
|
|
7
|
+
* llega coincide con el anclado. Si no coincide, el agente entregó una cosa y
|
|
8
|
+
* te está enseñando otra.
|
|
9
|
+
*
|
|
10
|
+
* La URL sale del metadata on-chain, o sea que la escribe un desconocido: se
|
|
11
|
+
* valida antes de pedirle nada, y la respuesta se lee con un tope de tamaño.
|
|
12
|
+
*/
|
|
13
|
+
/** El mensaje que firma el cliente. Debe coincidir con el bot y el frontend. */
|
|
14
|
+
export declare function resultSignMessage(taskId: bigint): string;
|
|
15
|
+
/**
|
|
16
|
+
* Rechaza una URL que apunte a algo que no sea internet pública.
|
|
17
|
+
*
|
|
18
|
+
* Sin esto, cualquiera puede registrar un agente cuyo endpoint sea
|
|
19
|
+
* `http://169.254.169.254/latest/meta-data/` y usar este servidor para leer las
|
|
20
|
+
* credenciales de la máquina donde corre.
|
|
21
|
+
*
|
|
22
|
+
* Queda una ventana de DNS rebinding: se resuelve aquí y `fetch` vuelve a
|
|
23
|
+
* resolver por su cuenta. Cerrarla del todo exige un agente HTTP a medida; el
|
|
24
|
+
* riesgo residual es aceptable porque la respuesta solo se compara contra un
|
|
25
|
+
* hash y nunca se ejecuta.
|
|
26
|
+
*/
|
|
27
|
+
export declare function assertPublicUrl(raw: string): Promise<URL>;
|
|
28
|
+
/** Descarga acotada: se corta en cuanto se pasa del tope. */
|
|
29
|
+
export declare function fetchResultText(botUrl: string, taskId: bigint, client: string, signature: string): Promise<string>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panal-mcp — recogida del resultado desde el endpoint del agente.
|
|
3
|
+
*
|
|
4
|
+
* El resultado NO viaja on-chain: en la cadena solo queda su keccak256. El
|
|
5
|
+
* texto se le pide al endpoint que el agente publica en su metadata, firmando
|
|
6
|
+
* como cliente con EIP-191 (sin gas), y se comprueba que el hash de lo que
|
|
7
|
+
* llega coincide con el anclado. Si no coincide, el agente entregó una cosa y
|
|
8
|
+
* te está enseñando otra.
|
|
9
|
+
*
|
|
10
|
+
* La URL sale del metadata on-chain, o sea que la escribe un desconocido: se
|
|
11
|
+
* valida antes de pedirle nada, y la respuesta se lee con un tope de tamaño.
|
|
12
|
+
*/
|
|
13
|
+
import { lookup } from 'node:dns/promises';
|
|
14
|
+
import { isIP } from 'node:net';
|
|
15
|
+
/** Tope de la respuesta. Sin esto, un endpoint hostil tumba el proceso. */
|
|
16
|
+
const MAX_BYTES = 512 * 1024;
|
|
17
|
+
const TIMEOUT_MS = 20_000;
|
|
18
|
+
/** El mensaje que firma el cliente. Debe coincidir con el bot y el frontend. */
|
|
19
|
+
export function resultSignMessage(taskId) {
|
|
20
|
+
return `Panal resultado #${taskId.toString()}`;
|
|
21
|
+
}
|
|
22
|
+
/** ¿Esta IP apunta dentro de nuestra propia red? */
|
|
23
|
+
function isPrivateIp(ip) {
|
|
24
|
+
if (isIP(ip) === 6) {
|
|
25
|
+
const v6 = ip.toLowerCase();
|
|
26
|
+
if (v6 === '::1' || v6 === '::')
|
|
27
|
+
return true;
|
|
28
|
+
if (/^f[cd]/.test(v6) || v6.startsWith('fe80'))
|
|
29
|
+
return true;
|
|
30
|
+
// IPv4 mapeada: ::ffff:10.0.0.1
|
|
31
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
|
|
32
|
+
return mapped ? isPrivateIp(mapped[1]) : false;
|
|
33
|
+
}
|
|
34
|
+
const [a = 0, b = 0] = ip.split('.').map(Number);
|
|
35
|
+
return (a === 0 ||
|
|
36
|
+
a === 10 ||
|
|
37
|
+
a === 127 ||
|
|
38
|
+
(a === 169 && b === 254) || // metadatos de la nube: credenciales
|
|
39
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
40
|
+
(a === 192 && b === 168) ||
|
|
41
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
|
42
|
+
a >= 224);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Rechaza una URL que apunte a algo que no sea internet pública.
|
|
46
|
+
*
|
|
47
|
+
* Sin esto, cualquiera puede registrar un agente cuyo endpoint sea
|
|
48
|
+
* `http://169.254.169.254/latest/meta-data/` y usar este servidor para leer las
|
|
49
|
+
* credenciales de la máquina donde corre.
|
|
50
|
+
*
|
|
51
|
+
* Queda una ventana de DNS rebinding: se resuelve aquí y `fetch` vuelve a
|
|
52
|
+
* resolver por su cuenta. Cerrarla del todo exige un agente HTTP a medida; el
|
|
53
|
+
* riesgo residual es aceptable porque la respuesta solo se compara contra un
|
|
54
|
+
* hash y nunca se ejecuta.
|
|
55
|
+
*/
|
|
56
|
+
export async function assertPublicUrl(raw) {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`El endpoint del agente no es una URL válida: ${raw}`);
|
|
63
|
+
}
|
|
64
|
+
if (url.protocol !== 'https:') {
|
|
65
|
+
// La petición lleva una firma del cliente en la query: en claro la lee cualquiera.
|
|
66
|
+
throw new Error(`El endpoint del agente tiene que ser https, y es ${url.protocol}//`);
|
|
67
|
+
}
|
|
68
|
+
if (url.username || url.password)
|
|
69
|
+
throw new Error('El endpoint lleva credenciales embebidas: se rechaza.');
|
|
70
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
71
|
+
if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) {
|
|
72
|
+
throw new Error(`El endpoint apunta a un nombre local (${host}): se rechaza.`);
|
|
73
|
+
}
|
|
74
|
+
const ips = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address);
|
|
75
|
+
if (!ips.length)
|
|
76
|
+
throw new Error(`No se pudo resolver ${host}.`);
|
|
77
|
+
for (const ip of ips) {
|
|
78
|
+
if (isPrivateIp(ip))
|
|
79
|
+
throw new Error(`El endpoint apunta a una dirección interna (${ip}): se rechaza.`);
|
|
80
|
+
}
|
|
81
|
+
return url;
|
|
82
|
+
}
|
|
83
|
+
/** Descarga acotada: se corta en cuanto se pasa del tope. */
|
|
84
|
+
export async function fetchResultText(botUrl, taskId, client, signature) {
|
|
85
|
+
const base = await assertPublicUrl(botUrl);
|
|
86
|
+
const url = new URL(`/result/${taskId}`, base);
|
|
87
|
+
url.searchParams.set('address', client);
|
|
88
|
+
url.searchParams.set('signature', signature);
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`El endpoint del agente respondió ${res.status}. ¿Sigue en pie y la firma es la del cliente?`);
|
|
95
|
+
}
|
|
96
|
+
const reader = res.body?.getReader();
|
|
97
|
+
if (!reader)
|
|
98
|
+
throw new Error('El endpoint del agente respondió sin cuerpo.');
|
|
99
|
+
const chunks = [];
|
|
100
|
+
let total = 0;
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done)
|
|
104
|
+
break;
|
|
105
|
+
total += value.byteLength;
|
|
106
|
+
if (total > MAX_BYTES) {
|
|
107
|
+
await reader.cancel();
|
|
108
|
+
throw new Error(`El resultado pasa de ${MAX_BYTES / 1024} KB: se corta por seguridad.`);
|
|
109
|
+
}
|
|
110
|
+
chunks.push(value);
|
|
111
|
+
}
|
|
112
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
113
|
+
const parsed = JSON.parse(body);
|
|
114
|
+
if (typeof parsed.resultText !== 'string')
|
|
115
|
+
throw new Error('La respuesta del agente no trae `resultText`.');
|
|
116
|
+
return parsed.resultText;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panal-mcp — topes de gasto y presupuestos.
|
|
3
|
+
*
|
|
4
|
+
* Un MCP con clave privada es un modelo de lenguaje gastando dinero real a
|
|
5
|
+
* petición de quien esté conversando. Los límites se aplican AQUÍ, en el
|
|
6
|
+
* servidor, y nunca en el prompt: un prompt se puede negociar, un `if` no.
|
|
7
|
+
*/
|
|
8
|
+
import type { Address } from 'viem';
|
|
9
|
+
export interface Quote {
|
|
10
|
+
id: string;
|
|
11
|
+
worker: Address;
|
|
12
|
+
agentName: string;
|
|
13
|
+
brief: string;
|
|
14
|
+
amount: bigint;
|
|
15
|
+
currency: Address;
|
|
16
|
+
symbol: string;
|
|
17
|
+
botUrl: string | null;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Presupuestos vivos, en memoria.
|
|
22
|
+
*
|
|
23
|
+
* No se persisten a propósito: un presupuesto que sobrevive a un reinicio es un
|
|
24
|
+
* precio viejo esperando a aplicarse. Si el servidor se cae, se vuelve a pedir.
|
|
25
|
+
*/
|
|
26
|
+
export declare class QuoteBook {
|
|
27
|
+
private readonly quotes;
|
|
28
|
+
issue(data: Omit<Quote, 'id' | 'expiresAt'>): Quote;
|
|
29
|
+
/** Devuelve el presupuesto, o el motivo por el que no vale. */
|
|
30
|
+
redeem(id: string): {
|
|
31
|
+
quote: Quote;
|
|
32
|
+
} | {
|
|
33
|
+
error: string;
|
|
34
|
+
};
|
|
35
|
+
private prune;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Registro de gasto diario, persistido en disco.
|
|
39
|
+
*
|
|
40
|
+
* Un tope que se borra al reiniciar no es un tope: bastaría con reiniciar el
|
|
41
|
+
* servidor para volver a gastar el presupuesto entero. Se guarda el día UTC
|
|
42
|
+
* junto al importe, así que el contador se reinicia solo al cambiar de día.
|
|
43
|
+
*/
|
|
44
|
+
export declare class SpendLedger {
|
|
45
|
+
private readonly file;
|
|
46
|
+
private readonly onWarn;
|
|
47
|
+
constructor(file: string, onWarn: (msg: string) => void);
|
|
48
|
+
static defaultFile(): string;
|
|
49
|
+
private static utcDay;
|
|
50
|
+
spentToday(): bigint;
|
|
51
|
+
record(wei: bigint): void;
|
|
52
|
+
}
|
|
53
|
+
export interface Limits {
|
|
54
|
+
maxPerTaskWei: bigint;
|
|
55
|
+
dailyBudgetWei: bigint;
|
|
56
|
+
deadlineHours: number;
|
|
57
|
+
}
|
|
58
|
+
export declare function limitsFromEnv(): Limits;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panal-mcp — topes de gasto y presupuestos.
|
|
3
|
+
*
|
|
4
|
+
* Un MCP con clave privada es un modelo de lenguaje gastando dinero real a
|
|
5
|
+
* petición de quien esté conversando. Los límites se aplican AQUÍ, en el
|
|
6
|
+
* servidor, y nunca en el prompt: un prompt se puede negociar, un `if` no.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
/** Vida de un presupuesto. Corto a propósito: el precio del agente puede cambiar. */
|
|
12
|
+
const QUOTE_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Presupuestos vivos, en memoria.
|
|
15
|
+
*
|
|
16
|
+
* No se persisten a propósito: un presupuesto que sobrevive a un reinicio es un
|
|
17
|
+
* precio viejo esperando a aplicarse. Si el servidor se cae, se vuelve a pedir.
|
|
18
|
+
*/
|
|
19
|
+
export class QuoteBook {
|
|
20
|
+
quotes = new Map();
|
|
21
|
+
issue(data) {
|
|
22
|
+
this.prune();
|
|
23
|
+
const quote = { ...data, id: randomUUID(), expiresAt: Date.now() + QUOTE_TTL_MS };
|
|
24
|
+
this.quotes.set(quote.id, quote);
|
|
25
|
+
return quote;
|
|
26
|
+
}
|
|
27
|
+
/** Devuelve el presupuesto, o el motivo por el que no vale. */
|
|
28
|
+
redeem(id) {
|
|
29
|
+
this.prune();
|
|
30
|
+
const quote = this.quotes.get(id);
|
|
31
|
+
if (!quote) {
|
|
32
|
+
return {
|
|
33
|
+
error: 'Ese presupuesto no existe o ya caducó (duran 5 minutos). ' +
|
|
34
|
+
'Pide uno nuevo con panal_quote_hire y enséñaselo a la persona antes de contratar.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// De un solo uso: sin esto, un reintento del modelo contrataría dos veces.
|
|
38
|
+
this.quotes.delete(id);
|
|
39
|
+
return { quote };
|
|
40
|
+
}
|
|
41
|
+
prune() {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const [id, q] of this.quotes)
|
|
44
|
+
if (q.expiresAt <= now)
|
|
45
|
+
this.quotes.delete(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Registro de gasto diario, persistido en disco.
|
|
50
|
+
*
|
|
51
|
+
* Un tope que se borra al reiniciar no es un tope: bastaría con reiniciar el
|
|
52
|
+
* servidor para volver a gastar el presupuesto entero. Se guarda el día UTC
|
|
53
|
+
* junto al importe, así que el contador se reinicia solo al cambiar de día.
|
|
54
|
+
*/
|
|
55
|
+
export class SpendLedger {
|
|
56
|
+
file;
|
|
57
|
+
onWarn;
|
|
58
|
+
constructor(file, onWarn) {
|
|
59
|
+
this.file = file;
|
|
60
|
+
this.onWarn = onWarn;
|
|
61
|
+
}
|
|
62
|
+
static defaultFile() {
|
|
63
|
+
return process.env.MCP_SPEND_FILE?.trim() || resolve(process.cwd(), '.panal-mcp', 'spend.json');
|
|
64
|
+
}
|
|
65
|
+
static utcDay() {
|
|
66
|
+
return new Date().toISOString().slice(0, 10);
|
|
67
|
+
}
|
|
68
|
+
spentToday() {
|
|
69
|
+
try {
|
|
70
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
71
|
+
if (raw.day !== SpendLedger.utcDay())
|
|
72
|
+
return 0n;
|
|
73
|
+
return BigInt(raw.spentWei ?? '0');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Sin archivo, ilegible o corrupto: se empieza de cero. Nunca lanza,
|
|
77
|
+
// porque un ledger roto no debe dejar el servidor inservible.
|
|
78
|
+
return 0n;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
record(wei) {
|
|
82
|
+
const total = this.spentToday() + wei;
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
85
|
+
// Escritura atómica: un corte a media escritura dejaría un JSON truncado
|
|
86
|
+
// que se leería como 0 y borraría el tope del día.
|
|
87
|
+
const tmp = `${this.file}.tmp`;
|
|
88
|
+
writeFileSync(tmp, JSON.stringify({ day: SpendLedger.utcDay(), spentWei: total.toString() }, null, 2));
|
|
89
|
+
renameSync(tmp, this.file);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
this.onWarn(`no se pudo registrar el gasto en ${this.file}: ${err instanceof Error ? err.message : err}. ` +
|
|
93
|
+
'El tope diario deja de ser fiable entre reinicios.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function limitsFromEnv() {
|
|
98
|
+
const parse = (name, fallback) => {
|
|
99
|
+
const raw = process.env[name]?.trim();
|
|
100
|
+
if (!raw)
|
|
101
|
+
return fallback;
|
|
102
|
+
try {
|
|
103
|
+
const value = BigInt(raw);
|
|
104
|
+
return value >= 0n ? value : fallback;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const hours = Number(process.env.MCP_TASK_DEADLINE_HOURS?.trim() || '24');
|
|
111
|
+
return {
|
|
112
|
+
maxPerTaskWei: parse('MCP_MAX_PER_TASK_WEI', 10n ** 18n), // 1 MON / $PANAL
|
|
113
|
+
dailyBudgetWei: parse('MCP_DAILY_BUDGET_WEI', 5n * 10n ** 18n),
|
|
114
|
+
deadlineHours: Number.isFinite(hours) && hours > 0 ? hours : 24,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* panal-mcp — servidor MCP de Panal.
|
|
4
|
+
*
|
|
5
|
+
* npx panal-mcp
|
|
6
|
+
*
|
|
7
|
+
* Deja que un modelo busque agentes de IA en Panal (Monad mainnet), pida
|
|
8
|
+
* presupuesto y —si se le autoriza explícitamente— los contrate pagando de
|
|
9
|
+
* verdad. Sin configuración arranca en SOLO LECTURA contra mainnet.
|
|
10
|
+
*
|
|
11
|
+
* Para contratar hacen falta DOS cosas, y tener solo una no basta:
|
|
12
|
+
* MCP_ENABLE_WRITES=true consentimiento explícito
|
|
13
|
+
* MCP_PRIVATE_KEY=0x… wallet dedicada del CLIENTE, con fondos
|
|
14
|
+
*
|
|
15
|
+
* Topes (todos opcionales, con valores conservadores por defecto):
|
|
16
|
+
* MCP_MAX_PER_TASK_WEI por encargo (default 1e18 = 1 MON/$PANAL)
|
|
17
|
+
* MCP_DAILY_BUDGET_WEI por día UTC (default 5e18)
|
|
18
|
+
* MCP_TASK_DEADLINE_HOURS plazo de entrega (default 24)
|
|
19
|
+
* MCP_SPEND_FILE dónde persistir el gasto del día
|
|
20
|
+
*
|
|
21
|
+
* Protocolo: JSON-RPC 2.0 sobre stdio, implementado a mano. stdout está
|
|
22
|
+
* RESERVADO para los mensajes del protocolo —un `console.log` suelto ahí
|
|
23
|
+
* corrompe la sesión entera—, así que todo el registro va por stderr.
|
|
24
|
+
*/
|
|
25
|
+
import { parseEther } from 'viem';
|
|
26
|
+
interface Tool {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
inputSchema: Record<string, unknown>;
|
|
30
|
+
handler: (args: Record<string, unknown>) => Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
export declare const _tools: Tool[];
|
|
33
|
+
export { parseEther };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* panal-mcp — servidor MCP de Panal.
|
|
4
|
+
*
|
|
5
|
+
* npx panal-mcp
|
|
6
|
+
*
|
|
7
|
+
* Deja que un modelo busque agentes de IA en Panal (Monad mainnet), pida
|
|
8
|
+
* presupuesto y —si se le autoriza explícitamente— los contrate pagando de
|
|
9
|
+
* verdad. Sin configuración arranca en SOLO LECTURA contra mainnet.
|
|
10
|
+
*
|
|
11
|
+
* Para contratar hacen falta DOS cosas, y tener solo una no basta:
|
|
12
|
+
* MCP_ENABLE_WRITES=true consentimiento explícito
|
|
13
|
+
* MCP_PRIVATE_KEY=0x… wallet dedicada del CLIENTE, con fondos
|
|
14
|
+
*
|
|
15
|
+
* Topes (todos opcionales, con valores conservadores por defecto):
|
|
16
|
+
* MCP_MAX_PER_TASK_WEI por encargo (default 1e18 = 1 MON/$PANAL)
|
|
17
|
+
* MCP_DAILY_BUDGET_WEI por día UTC (default 5e18)
|
|
18
|
+
* MCP_TASK_DEADLINE_HOURS plazo de entrega (default 24)
|
|
19
|
+
* MCP_SPEND_FILE dónde persistir el gasto del día
|
|
20
|
+
*
|
|
21
|
+
* Protocolo: JSON-RPC 2.0 sobre stdio, implementado a mano. stdout está
|
|
22
|
+
* RESERVADO para los mensajes del protocolo —un `console.log` suelto ahí
|
|
23
|
+
* corrompe la sesión entera—, así que todo el registro va por stderr.
|
|
24
|
+
*/
|
|
25
|
+
import { createInterface } from 'node:readline';
|
|
26
|
+
import { formatEther, isAddress, keccak256, parseEther, toBytes } from 'viem';
|
|
27
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
28
|
+
import { NATIVE_CURRENCY, TaskStatus, createPanalClient, } from '@panal/sdk';
|
|
29
|
+
import { QuoteBook, SpendLedger, limitsFromEnv } from './limits.js';
|
|
30
|
+
import { fetchResultText, resultSignMessage } from './fetch-result.js';
|
|
31
|
+
const SERVER_NAME = 'panal';
|
|
32
|
+
const SERVER_VERSION = '0.1.0';
|
|
33
|
+
/** Versiones del protocolo que sabemos hablar; la primera es la preferida. */
|
|
34
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
35
|
+
const EXPLORER = 'https://monadvision.com';
|
|
36
|
+
/** Registro. SIEMPRE por stderr: stdout es del protocolo. */
|
|
37
|
+
function log(msg) {
|
|
38
|
+
process.stderr.write(`[panal-mcp] ${msg}\n`);
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Configuración
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const limits = limitsFromEnv();
|
|
44
|
+
const quotes = new QuoteBook();
|
|
45
|
+
const ledger = new SpendLedger(SpendLedger.defaultFile(), (m) => log(`⚠️ ${m}`));
|
|
46
|
+
const writesRequested = (process.env.MCP_ENABLE_WRITES ?? '').trim().toLowerCase() === 'true';
|
|
47
|
+
const account = (() => {
|
|
48
|
+
const key = process.env.MCP_PRIVATE_KEY?.trim();
|
|
49
|
+
if (!key)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
52
|
+
log('MCP_PRIVATE_KEY no tiene el formato 0x + 64 hex: se ignora y el servidor queda en solo lectura.');
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return privateKeyToAccount(key);
|
|
56
|
+
})();
|
|
57
|
+
const panal = createPanalClient({
|
|
58
|
+
rpcUrl: process.env.RPC_URL?.trim(),
|
|
59
|
+
account,
|
|
60
|
+
});
|
|
61
|
+
/** Motivo por el que no se puede escribir, o null si sí se puede. */
|
|
62
|
+
function writesBlockedReason() {
|
|
63
|
+
if (!writesRequested) {
|
|
64
|
+
return ('Este servidor está en SOLO LECTURA. Para contratar hay que arrancarlo con ' +
|
|
65
|
+
'MCP_ENABLE_WRITES=true y una MCP_PRIVATE_KEY con fondos. Dile a la persona que lo configure ella: ' +
|
|
66
|
+
'no intentes rodearlo.');
|
|
67
|
+
}
|
|
68
|
+
if (!account) {
|
|
69
|
+
return 'MCP_ENABLE_WRITES está activo pero no hay una MCP_PRIVATE_KEY válida, así que no hay wallet con la que pagar.';
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Presentación
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
function symbolOf(currency) {
|
|
77
|
+
return currency.toLowerCase() === NATIVE_CURRENCY.toLowerCase() ? 'MON' : '$PANAL';
|
|
78
|
+
}
|
|
79
|
+
function renderAgent(agent) {
|
|
80
|
+
const price = `${formatEther(agent.pricePerTask)} ${symbolOf(agent.currency)}`;
|
|
81
|
+
const skills = agent.metadata.skills.length ? agent.metadata.skills.join(', ') : '(sin skills declaradas)';
|
|
82
|
+
return [
|
|
83
|
+
`${agent.metadata.name || '(sin nombre)'} — ${agent.address}`,
|
|
84
|
+
` Estado: ${agent.active ? 'activo' : 'DADO DE BAJA (no acepta encargos)'}`,
|
|
85
|
+
` Precio: ${price} por tarea`,
|
|
86
|
+
` Skills: ${skills}`,
|
|
87
|
+
agent.metadata.description ? ` Descripción: ${agent.metadata.description}` : null,
|
|
88
|
+
agent.metadata.botUrl ? ` Endpoint: ${agent.metadata.botUrl}` : null,
|
|
89
|
+
` Ficha: ${EXPLORER}/address/${agent.address}`,
|
|
90
|
+
]
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n');
|
|
93
|
+
}
|
|
94
|
+
function renderTask(task) {
|
|
95
|
+
const deadline = new Date(Number(task.deadline) * 1000).toISOString();
|
|
96
|
+
return [
|
|
97
|
+
`Tarea #${task.id} — ${TaskStatus[task.status] ?? task.status}`,
|
|
98
|
+
` Cliente: ${task.client}`,
|
|
99
|
+
` Agente: ${task.worker}`,
|
|
100
|
+
` Importe: ${formatEther(task.amount)} ${symbolOf(task.currency)}`,
|
|
101
|
+
` Plazo: ${deadline}`,
|
|
102
|
+
` Hash del encargo: ${task.taskHash}`,
|
|
103
|
+
task.resultHash && !/^0x0+$/.test(task.resultHash) ? ` Hash del resultado: ${task.resultHash}` : null,
|
|
104
|
+
]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join('\n');
|
|
107
|
+
}
|
|
108
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
109
|
+
const READ_TOOLS = [
|
|
110
|
+
{
|
|
111
|
+
name: 'panal_search_agents',
|
|
112
|
+
description: 'Busca agentes de IA en el marketplace Panal (Monad mainnet) por texto libre sobre su nombre, ' +
|
|
113
|
+
'descripción y skills. Sin búsqueda devuelve todos los activos. Datos leídos de la cadena en vivo.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
query: { type: 'string', description: 'Qué buscas, p. ej. "traducción" o "resúmenes legales".' },
|
|
118
|
+
include_inactive: { type: 'boolean', description: 'Incluir agentes dados de baja. Por defecto no.' },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
handler: async (args) => {
|
|
122
|
+
const found = await panal.searchAgents(str(args.query), {
|
|
123
|
+
includeInactive: args.include_inactive === true,
|
|
124
|
+
});
|
|
125
|
+
if (!found.length) {
|
|
126
|
+
return str(args.query)
|
|
127
|
+
? `Ningún agente activo encaja con "${str(args.query)}". Prueba con un término más general o mira todos sin búsqueda.`
|
|
128
|
+
: 'Ahora mismo no hay agentes activos en el marketplace.';
|
|
129
|
+
}
|
|
130
|
+
return `${found.length} agente(s):\n\n${found.map(renderAgent).join('\n\n')}`;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'panal_get_agent',
|
|
135
|
+
description: 'Ficha completa de un agente de Panal por su dirección.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: { address: { type: 'string', description: 'Dirección 0x… del agente.' } },
|
|
139
|
+
required: ['address'],
|
|
140
|
+
},
|
|
141
|
+
handler: async (args) => {
|
|
142
|
+
const address = str(args.address);
|
|
143
|
+
if (!address || !isAddress(address))
|
|
144
|
+
return 'Necesito una dirección 0x válida.';
|
|
145
|
+
return renderAgent(await panal.getAgent(address));
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'panal_get_task',
|
|
150
|
+
description: 'Estado de un encargo en el escrow de Panal: importe, plazo, agente y si ya se entregó.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: { task_id: { type: 'number', description: 'Id numérico de la tarea.' } },
|
|
154
|
+
required: ['task_id'],
|
|
155
|
+
},
|
|
156
|
+
handler: async (args) => {
|
|
157
|
+
const id = Number(args.task_id);
|
|
158
|
+
if (!Number.isInteger(id) || id < 0)
|
|
159
|
+
return 'El id de la tarea es un entero mayor o igual que cero.';
|
|
160
|
+
const count = await panal.getTaskCount();
|
|
161
|
+
if (BigInt(id) >= count)
|
|
162
|
+
return `La tarea #${id} no existe: ahora mismo hay ${count}.`;
|
|
163
|
+
return renderTask(await panal.getTask(BigInt(id)));
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'panal_marketplace_stats',
|
|
168
|
+
description: 'Cifras del marketplace Panal: cuántos agentes hay, cuántos activos y cuántos encargos van.',
|
|
169
|
+
inputSchema: { type: 'object', properties: {} },
|
|
170
|
+
handler: async () => {
|
|
171
|
+
const [agents, taskCount] = await Promise.all([panal.listAgents(), panal.getTaskCount()]);
|
|
172
|
+
const active = agents.filter((a) => a.active);
|
|
173
|
+
return [
|
|
174
|
+
`Agentes registrados: ${agents.length}`,
|
|
175
|
+
`Agentes activos: ${active.length}`,
|
|
176
|
+
`Encargos creados: ${taskCount}`,
|
|
177
|
+
`Escrow: ${panal.addresses.escrow}`,
|
|
178
|
+
`Registry: ${panal.addresses.registry}`,
|
|
179
|
+
'',
|
|
180
|
+
active.length ? `Activos ahora:\n${active.map(renderAgent).join('\n\n')}` : 'No hay agentes activos.',
|
|
181
|
+
].join('\n');
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
const WRITE_TOOLS = [
|
|
186
|
+
{
|
|
187
|
+
name: 'panal_wallet',
|
|
188
|
+
description: 'Estado de la wallet con la que este servidor pagaría: dirección, saldo y cuánto queda del ' +
|
|
189
|
+
'presupuesto de hoy. Úsala antes de contratar para saber si hay fondos.',
|
|
190
|
+
inputSchema: { type: 'object', properties: {} },
|
|
191
|
+
handler: async () => {
|
|
192
|
+
const blocked = writesBlockedReason();
|
|
193
|
+
if (blocked)
|
|
194
|
+
return blocked;
|
|
195
|
+
const address = account.address;
|
|
196
|
+
const [balance, spent] = [await panal.publicClient.getBalance({ address }), ledger.spentToday()];
|
|
197
|
+
const left = limits.dailyBudgetWei > spent ? limits.dailyBudgetWei - spent : 0n;
|
|
198
|
+
return [
|
|
199
|
+
`Wallet: ${address}`,
|
|
200
|
+
`Saldo: ${formatEther(balance)} MON`,
|
|
201
|
+
`Tope por encargo: ${formatEther(limits.maxPerTaskWei)}`,
|
|
202
|
+
`Presupuesto de hoy: gastado ${formatEther(spent)} de ${formatEther(limits.dailyBudgetWei)} · queda ${formatEther(left)}`,
|
|
203
|
+
].join('\n');
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'panal_quote_hire',
|
|
208
|
+
description: 'Presupuesta un encargo SIN pagar nada. Devuelve el precio real del agente y un quote_id. ' +
|
|
209
|
+
'Enseña siempre el precio a la persona y espera su visto bueno antes de llamar a panal_hire.',
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: {
|
|
213
|
+
agent: { type: 'string', description: 'Dirección 0x… del agente.' },
|
|
214
|
+
brief: { type: 'string', description: 'El encargo, redactado con todo el detalle necesario.' },
|
|
215
|
+
},
|
|
216
|
+
required: ['agent', 'brief'],
|
|
217
|
+
},
|
|
218
|
+
handler: async (args) => {
|
|
219
|
+
const blocked = writesBlockedReason();
|
|
220
|
+
if (blocked)
|
|
221
|
+
return blocked;
|
|
222
|
+
const address = str(args.agent);
|
|
223
|
+
const brief = str(args.brief);
|
|
224
|
+
if (!address || !isAddress(address))
|
|
225
|
+
return 'Necesito una dirección 0x válida del agente.';
|
|
226
|
+
if (!brief)
|
|
227
|
+
return 'Necesito el texto del encargo.';
|
|
228
|
+
const agent = await panal.getAgent(address);
|
|
229
|
+
if (!agent.active)
|
|
230
|
+
return `${agent.metadata.name || address} está dado de baja y no acepta encargos.`;
|
|
231
|
+
const amount = agent.pricePerTask;
|
|
232
|
+
const symbol = symbolOf(agent.currency);
|
|
233
|
+
if (amount > limits.maxPerTaskWei) {
|
|
234
|
+
return (`Ese agente cobra ${formatEther(amount)} ${symbol} y el tope por encargo de este servidor es ` +
|
|
235
|
+
`${formatEther(limits.maxPerTaskWei)}. No se puede contratar sin que la persona suba MCP_MAX_PER_TASK_WEI.`);
|
|
236
|
+
}
|
|
237
|
+
const spent = ledger.spentToday();
|
|
238
|
+
if (spent + amount > limits.dailyBudgetWei) {
|
|
239
|
+
return (`Se pasaría del presupuesto de hoy: llevas ${formatEther(spent)} de ${formatEther(limits.dailyBudgetWei)} ` +
|
|
240
|
+
`y esto cuesta ${formatEther(amount)} ${symbol}.`);
|
|
241
|
+
}
|
|
242
|
+
const quote = quotes.issue({
|
|
243
|
+
worker: agent.address,
|
|
244
|
+
agentName: agent.metadata.name || agent.address,
|
|
245
|
+
brief,
|
|
246
|
+
amount,
|
|
247
|
+
currency: agent.currency,
|
|
248
|
+
symbol,
|
|
249
|
+
botUrl: agent.metadata.botUrl,
|
|
250
|
+
});
|
|
251
|
+
return [
|
|
252
|
+
`Presupuesto para ${quote.agentName}:`,
|
|
253
|
+
` Precio: ${formatEther(amount)} ${symbol}`,
|
|
254
|
+
` Plazo: ${limits.deadlineHours} h desde que se contrate`,
|
|
255
|
+
` Encargo: ${brief.length > 200 ? `${brief.slice(0, 200)}…` : brief}`,
|
|
256
|
+
'',
|
|
257
|
+
`quote_id: ${quote.id} (vale 5 minutos)`,
|
|
258
|
+
'',
|
|
259
|
+
'Enséñale este precio a la persona. Solo si dice que sí, llama a panal_hire con ese quote_id ' +
|
|
260
|
+
'y confirmed_by_user: true. Esto mueve dinero real.',
|
|
261
|
+
].join('\n');
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'panal_hire',
|
|
266
|
+
description: 'Contrata al agente y BLOQUEA EL PAGO REAL en el escrow. Exige un quote_id de panal_quote_hire y ' +
|
|
267
|
+
'que la persona haya dicho que sí de forma explícita. No lo llames por iniciativa propia.',
|
|
268
|
+
inputSchema: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: {
|
|
271
|
+
quote_id: { type: 'string', description: 'El id devuelto por panal_quote_hire.' },
|
|
272
|
+
confirmed_by_user: {
|
|
273
|
+
type: 'boolean',
|
|
274
|
+
description: 'true solo si la persona ha visto el precio y ha dado su visto bueno.',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
required: ['quote_id', 'confirmed_by_user'],
|
|
278
|
+
},
|
|
279
|
+
handler: async (args) => {
|
|
280
|
+
const blocked = writesBlockedReason();
|
|
281
|
+
if (blocked)
|
|
282
|
+
return blocked;
|
|
283
|
+
if (args.confirmed_by_user !== true) {
|
|
284
|
+
return 'No contrato sin confirmed_by_user: true. Enséñale el presupuesto a la persona y pregúntale primero.';
|
|
285
|
+
}
|
|
286
|
+
const id = str(args.quote_id);
|
|
287
|
+
if (!id)
|
|
288
|
+
return 'Falta el quote_id. Pide antes un presupuesto con panal_quote_hire.';
|
|
289
|
+
const redeemed = quotes.redeem(id);
|
|
290
|
+
if ('error' in redeemed)
|
|
291
|
+
return redeemed.error;
|
|
292
|
+
const quote = redeemed.quote;
|
|
293
|
+
// Los topes se re-evalúan al contratar: entre el presupuesto y el "sí"
|
|
294
|
+
// pueden haber pasado minutos y otros encargos.
|
|
295
|
+
const spent = ledger.spentToday();
|
|
296
|
+
if (spent + quote.amount > limits.dailyBudgetWei) {
|
|
297
|
+
return `Mientras tanto se agotó el presupuesto de hoy (${formatEther(spent)} de ${formatEther(limits.dailyBudgetWei)}).`;
|
|
298
|
+
}
|
|
299
|
+
const deadline = BigInt(Math.floor(Date.now() / 1000) + limits.deadlineHours * 3600);
|
|
300
|
+
try {
|
|
301
|
+
const result = await panal.hire({ agent: quote.worker, brief: quote.brief, deadline });
|
|
302
|
+
ledger.record(result.amount);
|
|
303
|
+
log(`contratada #${result.taskId} a ${quote.worker} por ${formatEther(result.amount)} ${quote.symbol}`);
|
|
304
|
+
return [
|
|
305
|
+
`Contratado. Tarea #${result.taskId} creada y pago bloqueado en el escrow.`,
|
|
306
|
+
` Agente: ${quote.agentName}`,
|
|
307
|
+
` Importe: ${formatEther(result.amount)} ${quote.symbol}`,
|
|
308
|
+
` tx: ${EXPLORER}/tx/${result.txHash}`,
|
|
309
|
+
'',
|
|
310
|
+
quote.botUrl
|
|
311
|
+
? `El agente publica endpoint (${quote.botUrl}): el resultado se podrá descargar de ahí.`
|
|
312
|
+
: 'Ese agente no publica endpoint: pídele el resultado por el canal que uses con él.',
|
|
313
|
+
'',
|
|
314
|
+
'IMPORTANTE: el encargo NO viaja on-chain, solo su hash. Hazle llegar el texto al agente.',
|
|
315
|
+
`Cuando entregue, revisa con panal_get_task ${result.taskId} y aprueba con panal_approve_task.`,
|
|
316
|
+
].join('\n');
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
320
|
+
log(`fallo al contratar: ${msg}`);
|
|
321
|
+
return `No se pudo contratar: ${msg}`;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: 'panal_get_result',
|
|
327
|
+
description: 'Recoge el resultado de una tarea ya entregada. El texto vive fuera de la cadena: se le pide al ' +
|
|
328
|
+
'endpoint del agente firmando como cliente, y se comprueba que su hash coincide con el anclado ' +
|
|
329
|
+
'on-chain. Solo funciona para tareas que contrató la wallet de este servidor.',
|
|
330
|
+
inputSchema: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
properties: { task_id: { type: 'number', description: 'Id de la tarea entregada.' } },
|
|
333
|
+
required: ['task_id'],
|
|
334
|
+
},
|
|
335
|
+
handler: async (args) => {
|
|
336
|
+
if (!account)
|
|
337
|
+
return 'Sin MCP_PRIVATE_KEY no se puede firmar la petición del resultado.';
|
|
338
|
+
const id = Number(args.task_id);
|
|
339
|
+
if (!Number.isInteger(id) || id < 0)
|
|
340
|
+
return 'El id de la tarea es un entero mayor o igual que cero.';
|
|
341
|
+
const taskId = BigInt(id);
|
|
342
|
+
const task = await panal.getTask(taskId);
|
|
343
|
+
if (task.client.toLowerCase() !== account.address.toLowerCase()) {
|
|
344
|
+
return `La tarea #${id} la contrató ${task.client}, no esta wallet. Solo el cliente puede recoger el resultado.`;
|
|
345
|
+
}
|
|
346
|
+
if (/^0x0+$/.test(task.resultHash)) {
|
|
347
|
+
return `La tarea #${id} todavía no tiene resultado entregado (estado: ${TaskStatus[task.status] ?? task.status}).`;
|
|
348
|
+
}
|
|
349
|
+
const agent = await panal.getAgent(task.worker);
|
|
350
|
+
if (!agent.metadata.botUrl) {
|
|
351
|
+
return (`El agente ${agent.metadata.name || task.worker} no publica endpoint, así que el resultado no se puede ` +
|
|
352
|
+
`descargar por aquí. En la cadena consta su hash (${task.resultHash}): pídeselo por vuestro canal y ` +
|
|
353
|
+
'comprueba que el hash coincide.');
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const signature = await account.signMessage({ message: resultSignMessage(taskId) });
|
|
357
|
+
const text = await fetchResultText(agent.metadata.botUrl, taskId, account.address, signature);
|
|
358
|
+
// Lo que importa de todo esto: que el texto sea EXACTAMENTE el que se
|
|
359
|
+
// ancló. Sin esta comprobación, el agente podría entregar una cosa
|
|
360
|
+
// on-chain y enseñarte otra distinta.
|
|
361
|
+
const actual = keccak256(toBytes(text));
|
|
362
|
+
if (actual.toLowerCase() !== task.resultHash.toLowerCase()) {
|
|
363
|
+
return (`⚠️ El resultado que devuelve el agente NO coincide con el anclado on-chain.\n` +
|
|
364
|
+
` esperado: ${task.resultHash}\n recibido: ${actual}\n\n` +
|
|
365
|
+
'No lo apruebes: o el agente cambió el texto después de entregar, o alguien alteró la respuesta. ' +
|
|
366
|
+
'Puedes abrir una disputa desde https://panal.lat/dashboard.');
|
|
367
|
+
}
|
|
368
|
+
return `Resultado de la tarea #${id} (hash verificado contra la cadena):\n\n${text}`;
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
return `No se pudo recoger el resultado: ${err instanceof Error ? err.message : err}`;
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'panal_approve_task',
|
|
377
|
+
description: 'Aprueba el resultado entregado, LIBERA EL PAGO al agente y le deja una valoración de 1 a 5. ' +
|
|
378
|
+
'Solo tras enseñarle el resultado a la persona y que ella decida la nota.',
|
|
379
|
+
inputSchema: {
|
|
380
|
+
type: 'object',
|
|
381
|
+
properties: {
|
|
382
|
+
task_id: { type: 'number', description: 'Id de la tarea entregada.' },
|
|
383
|
+
rating: { type: 'number', description: 'Valoración de 1 a 5 que ha decidido la persona.' },
|
|
384
|
+
confirmed_by_user: { type: 'boolean', description: 'true solo si la persona lo ha aprobado.' },
|
|
385
|
+
},
|
|
386
|
+
required: ['task_id', 'rating', 'confirmed_by_user'],
|
|
387
|
+
},
|
|
388
|
+
handler: async (args) => {
|
|
389
|
+
const blocked = writesBlockedReason();
|
|
390
|
+
if (blocked)
|
|
391
|
+
return blocked;
|
|
392
|
+
if (args.confirmed_by_user !== true) {
|
|
393
|
+
return 'No libero el pago sin confirmed_by_user: true. Enséñale el resultado a la persona primero.';
|
|
394
|
+
}
|
|
395
|
+
const id = Number(args.task_id);
|
|
396
|
+
const rating = Number(args.rating);
|
|
397
|
+
if (!Number.isInteger(id) || id < 0)
|
|
398
|
+
return 'El id de la tarea es un entero.';
|
|
399
|
+
if (!Number.isInteger(rating) || rating < 1 || rating > 5)
|
|
400
|
+
return 'La valoración es un entero de 1 a 5.';
|
|
401
|
+
try {
|
|
402
|
+
const hash = await panal.approveTask(BigInt(id), rating);
|
|
403
|
+
return `Pago liberado para la tarea #${id} con valoración ${rating}/5.\n tx: ${EXPLORER}/tx/${hash}`;
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
return `No se pudo aprobar: ${err instanceof Error ? err.message : err}`;
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
];
|
|
411
|
+
/** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
|
|
412
|
+
const TOOLS = [...READ_TOOLS, ...WRITE_TOOLS];
|
|
413
|
+
function send(payload) {
|
|
414
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
415
|
+
}
|
|
416
|
+
function reply(id, result) {
|
|
417
|
+
if (id === undefined || id === null)
|
|
418
|
+
return; // notificación: no se contesta
|
|
419
|
+
send({ jsonrpc: '2.0', id, result });
|
|
420
|
+
}
|
|
421
|
+
function replyError(id, code, message) {
|
|
422
|
+
if (id === undefined || id === null)
|
|
423
|
+
return;
|
|
424
|
+
send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
425
|
+
}
|
|
426
|
+
async function handle(req) {
|
|
427
|
+
switch (req.method) {
|
|
428
|
+
case 'initialize': {
|
|
429
|
+
const asked = typeof req.params?.protocolVersion === 'string' ? req.params.protocolVersion : undefined;
|
|
430
|
+
const version = asked && SUPPORTED_PROTOCOLS.includes(asked) ? asked : SUPPORTED_PROTOCOLS[0];
|
|
431
|
+
reply(req.id, {
|
|
432
|
+
protocolVersion: version,
|
|
433
|
+
capabilities: { tools: {} },
|
|
434
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case 'notifications/initialized':
|
|
439
|
+
case 'initialized':
|
|
440
|
+
return;
|
|
441
|
+
case 'ping':
|
|
442
|
+
reply(req.id, {});
|
|
443
|
+
return;
|
|
444
|
+
case 'tools/list':
|
|
445
|
+
reply(req.id, {
|
|
446
|
+
tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
case 'tools/call': {
|
|
450
|
+
const name = typeof req.params?.name === 'string' ? req.params.name : '';
|
|
451
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
452
|
+
if (!tool) {
|
|
453
|
+
replyError(req.id, -32602, `No existe la herramienta "${name}".`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const args = req.params?.arguments ?? {};
|
|
457
|
+
try {
|
|
458
|
+
const text = await tool.handler(args);
|
|
459
|
+
reply(req.id, { content: [{ type: 'text', text }] });
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
// Un error se devuelve como resultado con isError, no como error del
|
|
463
|
+
// protocolo: así el modelo lo lee y puede explicárselo a la persona.
|
|
464
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
465
|
+
log(`${name} falló: ${msg}`);
|
|
466
|
+
reply(req.id, { content: [{ type: 'text', text: `Error en ${name}: ${msg}` }], isError: true });
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
default:
|
|
471
|
+
replyError(req.id, -32601, `Método no soportado: ${req.method}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function main() {
|
|
475
|
+
const mode = writesBlockedReason() === null ? `ESCRITURA (${account.address})` : 'solo lectura';
|
|
476
|
+
log(`v${SERVER_VERSION} · Monad mainnet · ${mode}`);
|
|
477
|
+
if (writesRequested && !account)
|
|
478
|
+
log('⚠️ MCP_ENABLE_WRITES=true pero sin clave válida: sigue en solo lectura.');
|
|
479
|
+
const rl = createInterface({ input: process.stdin });
|
|
480
|
+
rl.on('line', (line) => {
|
|
481
|
+
const trimmed = line.trim();
|
|
482
|
+
if (!trimmed)
|
|
483
|
+
return;
|
|
484
|
+
let req;
|
|
485
|
+
try {
|
|
486
|
+
req = JSON.parse(trimmed);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
log(`línea que no es JSON, ignorada: ${trimmed.slice(0, 80)}`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
void handle(req).catch((err) => {
|
|
493
|
+
log(`fallo interno: ${err instanceof Error ? err.message : err}`);
|
|
494
|
+
replyError(req.id, -32603, 'Error interno del servidor.');
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
rl.on('close', () => process.exit(0));
|
|
498
|
+
}
|
|
499
|
+
main();
|
|
500
|
+
// Solo para las pruebas: permite invocar una herramienta sin levantar el stdio.
|
|
501
|
+
export const _tools = TOOLS;
|
|
502
|
+
export { parseEther };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "panal-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Servidor MCP de Panal: busca y contrata agentes de IA on-chain desde Claude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/AgentHiv/Panal.git",
|
|
10
|
+
"directory": "mcp"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://panal.lat",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"panal",
|
|
17
|
+
"monad",
|
|
18
|
+
"ai-agents",
|
|
19
|
+
"claude"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"panal-mcp": "./dist/server.js"
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/server.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@panal/sdk": "^0.1.0",
|
|
34
|
+
"viem": "^2.21.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.6.0",
|
|
38
|
+
"tsx": "^4.19.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
+
"start": "tsx src/server.ts",
|
|
44
|
+
"test": "tsx test/mcp.test.ts"
|
|
45
|
+
}
|
|
46
|
+
}
|