panal-mcp 0.3.0 → 0.3.1
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/dist/fetch-result.d.ts +13 -16
- package/dist/fetch-result.js +31 -70
- package/dist/server.js +5 -3
- package/package.json +10 -9
package/dist/fetch-result.d.ts
CHANGED
|
@@ -10,8 +10,18 @@
|
|
|
10
10
|
* La URL sale del metadata on-chain, o sea que la escribe un desconocido: se
|
|
11
11
|
* valida antes de pedirle nada, y la respuesta se lee con un tope de tamaño.
|
|
12
12
|
*/
|
|
13
|
-
/**
|
|
14
|
-
export declare
|
|
13
|
+
/** Cuánto vale una firma de descarga: abre toda la entrega, así que poco. */
|
|
14
|
+
export declare const VENTANA_FIRMA_S: number;
|
|
15
|
+
/**
|
|
16
|
+
* El mensaje que firma el cliente. Debe coincidir con el bot y el frontend.
|
|
17
|
+
*
|
|
18
|
+
* Lleva la caducidad dentro: cambiarla invalida la firma. Antes no caducaba, y
|
|
19
|
+
* como además viajaba en la query acababa en el log del proxy — un pase de
|
|
20
|
+
* acceso permanente escrito en un archivo de texto.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resultSignMessage(taskId: bigint, expira: number): string;
|
|
23
|
+
/** El segundo en el que caduca una firma emitida ahora. */
|
|
24
|
+
export declare function expiraEn(ventanaS?: number): number;
|
|
15
25
|
/** El mensaje que firma el cliente para MANDAR el encargo. Mismo pacto. */
|
|
16
26
|
export declare function briefSignMessage(taskId: bigint): string;
|
|
17
27
|
/**
|
|
@@ -27,18 +37,5 @@ export declare function briefSignMessage(taskId: bigint): string;
|
|
|
27
37
|
* pago YA está bloqueado, así que lo útil es contarlo, no romper.
|
|
28
38
|
*/
|
|
29
39
|
export declare function pushBrief(botUrl: string, taskId: bigint, brief: string, client: string, signature: string): Promise<string | null>;
|
|
30
|
-
/**
|
|
31
|
-
* Rechaza una URL que apunte a algo que no sea internet pública.
|
|
32
|
-
*
|
|
33
|
-
* Sin esto, cualquiera puede registrar un agente cuyo endpoint sea
|
|
34
|
-
* `http://169.254.169.254/latest/meta-data/` y usar este servidor para leer las
|
|
35
|
-
* credenciales de la máquina donde corre.
|
|
36
|
-
*
|
|
37
|
-
* Queda una ventana de DNS rebinding: se resuelve aquí y `fetch` vuelve a
|
|
38
|
-
* resolver por su cuenta. Cerrarla del todo exige un agente HTTP a medida; el
|
|
39
|
-
* riesgo residual es aceptable porque la respuesta solo se compara contra un
|
|
40
|
-
* hash y nunca se ejecuta.
|
|
41
|
-
*/
|
|
42
|
-
export declare function assertPublicUrl(raw: string): Promise<URL>;
|
|
43
40
|
/** Descarga acotada: se corta en cuanto se pasa del tope. */
|
|
44
|
-
export declare function fetchResultText(botUrl: string, taskId: bigint, client: string, signature: string): Promise<string>;
|
|
41
|
+
export declare function fetchResultText(botUrl: string, taskId: bigint, client: string, signature: string, expira: number): Promise<string>;
|
package/dist/fetch-result.js
CHANGED
|
@@ -10,14 +10,29 @@
|
|
|
10
10
|
* La URL sale del metadata on-chain, o sea que la escribe un desconocido: se
|
|
11
11
|
* valida antes de pedirle nada, y la respuesta se lee con un tope de tamaño.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// El guard de SSRF viene del SDK, no de una copia local. Había dos
|
|
14
|
+
// implementaciones equivalentes de lo mismo —la del SDK y una aquí—, y dos
|
|
15
|
+
// copias de un control de seguridad son una que se queda atrás sin que nadie
|
|
16
|
+
// se entere el día que la otra mejora.
|
|
17
|
+
import { assertPublicUrl } from '@panal/sdk';
|
|
15
18
|
/** Tope de la respuesta. Sin esto, un endpoint hostil tumba el proceso. */
|
|
16
19
|
const MAX_BYTES = 512 * 1024;
|
|
17
20
|
const TIMEOUT_MS = 20_000;
|
|
18
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
+
/** Cuánto vale una firma de descarga: abre toda la entrega, así que poco. */
|
|
22
|
+
export const VENTANA_FIRMA_S = 10 * 60;
|
|
23
|
+
/**
|
|
24
|
+
* El mensaje que firma el cliente. Debe coincidir con el bot y el frontend.
|
|
25
|
+
*
|
|
26
|
+
* Lleva la caducidad dentro: cambiarla invalida la firma. Antes no caducaba, y
|
|
27
|
+
* como además viajaba en la query acababa en el log del proxy — un pase de
|
|
28
|
+
* acceso permanente escrito en un archivo de texto.
|
|
29
|
+
*/
|
|
30
|
+
export function resultSignMessage(taskId, expira) {
|
|
31
|
+
return `Panal resultado #${taskId.toString()} · ${expira}`;
|
|
32
|
+
}
|
|
33
|
+
/** El segundo en el que caduca una firma emitida ahora. */
|
|
34
|
+
export function expiraEn(ventanaS = VENTANA_FIRMA_S) {
|
|
35
|
+
return Math.floor(Date.now() / 1000) + ventanaS;
|
|
21
36
|
}
|
|
22
37
|
/** El mensaje que firma el cliente para MANDAR el encargo. Mismo pacto. */
|
|
23
38
|
export function briefSignMessage(taskId) {
|
|
@@ -72,77 +87,23 @@ export async function pushBrief(botUrl, taskId, brief, client, signature) {
|
|
|
72
87
|
clearTimeout(timer);
|
|
73
88
|
}
|
|
74
89
|
}
|
|
75
|
-
/** ¿Esta IP apunta dentro de nuestra propia red? */
|
|
76
|
-
function isPrivateIp(ip) {
|
|
77
|
-
if (isIP(ip) === 6) {
|
|
78
|
-
const v6 = ip.toLowerCase();
|
|
79
|
-
if (v6 === '::1' || v6 === '::')
|
|
80
|
-
return true;
|
|
81
|
-
if (/^f[cd]/.test(v6) || v6.startsWith('fe80'))
|
|
82
|
-
return true;
|
|
83
|
-
// IPv4 mapeada: ::ffff:10.0.0.1
|
|
84
|
-
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
|
|
85
|
-
return mapped ? isPrivateIp(mapped[1]) : false;
|
|
86
|
-
}
|
|
87
|
-
const [a = 0, b = 0] = ip.split('.').map(Number);
|
|
88
|
-
return (a === 0 ||
|
|
89
|
-
a === 10 ||
|
|
90
|
-
a === 127 ||
|
|
91
|
-
(a === 169 && b === 254) || // metadatos de la nube: credenciales
|
|
92
|
-
(a === 172 && b >= 16 && b <= 31) ||
|
|
93
|
-
(a === 192 && b === 168) ||
|
|
94
|
-
(a === 100 && b >= 64 && b <= 127) || // CGNAT
|
|
95
|
-
a >= 224);
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Rechaza una URL que apunte a algo que no sea internet pública.
|
|
99
|
-
*
|
|
100
|
-
* Sin esto, cualquiera puede registrar un agente cuyo endpoint sea
|
|
101
|
-
* `http://169.254.169.254/latest/meta-data/` y usar este servidor para leer las
|
|
102
|
-
* credenciales de la máquina donde corre.
|
|
103
|
-
*
|
|
104
|
-
* Queda una ventana de DNS rebinding: se resuelve aquí y `fetch` vuelve a
|
|
105
|
-
* resolver por su cuenta. Cerrarla del todo exige un agente HTTP a medida; el
|
|
106
|
-
* riesgo residual es aceptable porque la respuesta solo se compara contra un
|
|
107
|
-
* hash y nunca se ejecuta.
|
|
108
|
-
*/
|
|
109
|
-
export async function assertPublicUrl(raw) {
|
|
110
|
-
let url;
|
|
111
|
-
try {
|
|
112
|
-
url = new URL(raw);
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
throw new Error(`The agent endpoint is not a valid URL: ${raw}`);
|
|
116
|
-
}
|
|
117
|
-
if (url.protocol !== 'https:') {
|
|
118
|
-
// La petición lleva una firma del cliente en la query: en claro la lee cualquiera.
|
|
119
|
-
throw new Error(`The agent endpoint must be https, and it is ${url.protocol}//`);
|
|
120
|
-
}
|
|
121
|
-
if (url.username || url.password)
|
|
122
|
-
throw new Error('The endpoint carries embedded credentials: rejected.');
|
|
123
|
-
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
124
|
-
if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) {
|
|
125
|
-
throw new Error(`The endpoint points at a local name (${host}): rejected.`);
|
|
126
|
-
}
|
|
127
|
-
const ips = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address);
|
|
128
|
-
if (!ips.length)
|
|
129
|
-
throw new Error(`Could not resolve ${host}.`);
|
|
130
|
-
for (const ip of ips) {
|
|
131
|
-
if (isPrivateIp(ip))
|
|
132
|
-
throw new Error(`The endpoint points at an internal address (${ip}): rejected.`);
|
|
133
|
-
}
|
|
134
|
-
return url;
|
|
135
|
-
}
|
|
136
90
|
/** Descarga acotada: se corta en cuanto se pasa del tope. */
|
|
137
|
-
export async function fetchResultText(botUrl, taskId, client, signature) {
|
|
91
|
+
export async function fetchResultText(botUrl, taskId, client, signature, expira) {
|
|
138
92
|
const base = await assertPublicUrl(botUrl);
|
|
139
93
|
const url = new URL(`/result/${taskId}`, base);
|
|
140
|
-
url.searchParams.set('address', client);
|
|
141
|
-
url.searchParams.set('signature', signature);
|
|
142
94
|
const controller = new AbortController();
|
|
143
95
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
144
96
|
try {
|
|
145
|
-
|
|
97
|
+
// En cabeceras, no en la query: por ahí acababan en el log del proxy.
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
headers: {
|
|
101
|
+
accept: 'application/json',
|
|
102
|
+
'x-panal-address': client,
|
|
103
|
+
'x-panal-signature': signature,
|
|
104
|
+
'x-panal-expira': String(expira),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
146
107
|
if (!res.ok) {
|
|
147
108
|
throw new Error(`The agent endpoint replied ${res.status}. Is it still up, and is the signature the client's?`);
|
|
148
109
|
}
|
package/dist/server.js
CHANGED
|
@@ -30,7 +30,7 @@ import { formatEther, isAddress, keccak256, parseEther, toBytes } from 'viem';
|
|
|
30
30
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
31
31
|
import { NATIVE_CURRENCY, TaskStatus, createPanalClient, } from '@panal/sdk';
|
|
32
32
|
import { QuoteBook, SpendLedger, limitsFromEnv } from './limits.js';
|
|
33
|
-
import { briefSignMessage, fetchResultText, pushBrief, resultSignMessage } from './fetch-result.js';
|
|
33
|
+
import { briefSignMessage, expiraEn, fetchResultText, pushBrief, resultSignMessage } from './fetch-result.js';
|
|
34
34
|
const SERVER_NAME = 'panal';
|
|
35
35
|
/**
|
|
36
36
|
* Sale del package.json, no de una constante a mano: la copiada se quedó en
|
|
@@ -537,8 +537,10 @@ const WRITE_TOOLS = [
|
|
|
537
537
|
'hash matches.');
|
|
538
538
|
}
|
|
539
539
|
try {
|
|
540
|
-
|
|
541
|
-
const
|
|
540
|
+
// La firma caduca: abre el resultado y todos los archivos de la tarea.
|
|
541
|
+
const expira = expiraEn();
|
|
542
|
+
const signature = await account.signMessage({ message: resultSignMessage(taskId, expira) });
|
|
543
|
+
const text = await fetchResultText(agent.metadata.botUrl, taskId, account.address, signature, expira);
|
|
542
544
|
// Lo que importa de todo esto: que el texto sea EXACTAMENTE el que se
|
|
543
545
|
// ancló. Sin esta comprobación, el agente podría entregar una cosa
|
|
544
546
|
// on-chain y enseñarte otra distinta.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panal-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Servidor MCP de Panal: busca y contrata agentes de IA on-chain desde Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,18 +29,19 @@
|
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=20"
|
|
31
31
|
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"start": "tsx src/server.ts",
|
|
36
|
+
"test": "tsx test/mcp.test.ts",
|
|
37
|
+
"prepublishOnly": "npm run build"
|
|
38
|
+
},
|
|
32
39
|
"dependencies": {
|
|
33
|
-
"@panal/sdk": "
|
|
40
|
+
"@panal/sdk": "workspace:^0.7.0",
|
|
34
41
|
"viem": "^2.21.0"
|
|
35
42
|
},
|
|
36
43
|
"devDependencies": {
|
|
37
44
|
"typescript": "^5.6.0",
|
|
38
45
|
"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
|
}
|
|
46
|
-
}
|
|
47
|
+
}
|